http_request action working nicely
This commit is contained in:
@@ -80,6 +80,7 @@ sensor:
|
|||||||
heartbeat_interval: 10
|
heartbeat_interval: 10
|
||||||
max_concurrent_sensors: 20
|
max_concurrent_sensors: 20
|
||||||
sensor_timeout: 120
|
sensor_timeout: 120
|
||||||
|
shutdown_timeout: 30
|
||||||
polling_interval: 5 # Check for new sensors every 5 seconds
|
polling_interval: 5 # Check for new sensors every 5 seconds
|
||||||
cleanup_interval: 60
|
cleanup_interval: 60
|
||||||
|
|
||||||
|
|||||||
@@ -407,6 +407,10 @@ pub struct SensorConfig {
|
|||||||
/// Sensor execution timeout in seconds
|
/// Sensor execution timeout in seconds
|
||||||
#[serde(default = "default_sensor_timeout")]
|
#[serde(default = "default_sensor_timeout")]
|
||||||
pub sensor_timeout: u64,
|
pub sensor_timeout: u64,
|
||||||
|
|
||||||
|
/// Graceful shutdown timeout in seconds
|
||||||
|
#[serde(default = "default_sensor_shutdown_timeout")]
|
||||||
|
pub shutdown_timeout: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sensor_poll_interval() -> u64 {
|
fn default_sensor_poll_interval() -> u64 {
|
||||||
@@ -417,6 +421,10 @@ fn default_sensor_timeout() -> u64 {
|
|||||||
30
|
30
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_sensor_shutdown_timeout() -> u64 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
/// Pack registry index configuration
|
/// Pack registry index configuration
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RegistryIndexConfig {
|
pub struct RegistryIndexConfig {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ impl ExecutionTimeoutMonitor {
|
|||||||
ORDER BY updated ASC
|
ORDER BY updated ASC
|
||||||
LIMIT 100", // Process in batches to avoid overwhelming system
|
LIMIT 100", // Process in batches to avoid overwhelming system
|
||||||
)
|
)
|
||||||
.bind("scheduled")
|
.bind(ExecutionStatus::Scheduled)
|
||||||
.bind(cutoff)
|
.bind(cutoff)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -186,7 +186,7 @@ impl ExecutionTimeoutMonitor {
|
|||||||
updated = NOW()
|
updated = NOW()
|
||||||
WHERE id = $3",
|
WHERE id = $3",
|
||||||
)
|
)
|
||||||
.bind("failed")
|
.bind(ExecutionStatus::Failed)
|
||||||
.bind(&result)
|
.bind(&result)
|
||||||
.bind(execution_id)
|
.bind(execution_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use anyhow::Result;
|
|||||||
use attune_common::config::Config;
|
use attune_common::config::Config;
|
||||||
use attune_sensor::service::SensorService;
|
use attune_sensor::service::SensorService;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use tokio::signal::unix::{signal, SignalKind};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -56,32 +57,38 @@ async fn main() -> Result<()> {
|
|||||||
info!("Message Queue: {}", mask_connection_string(&mq_config.url));
|
info!("Message Queue: {}", mask_connection_string(&mq_config.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create sensor service
|
// Create and start sensor service
|
||||||
let service = SensorService::new(config).await?;
|
let service = SensorService::new(config).await?;
|
||||||
|
|
||||||
info!("Sensor Service initialized successfully");
|
info!("Sensor Service initialized successfully");
|
||||||
|
|
||||||
// Set up graceful shutdown handler
|
// Start the service (spawns background tasks and returns)
|
||||||
let service_clone = service.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = tokio::signal::ctrl_c().await {
|
|
||||||
error!("Failed to listen for shutdown signal: {}", e);
|
|
||||||
} else {
|
|
||||||
info!("Shutdown signal received");
|
|
||||||
if let Err(e) = service_clone.stop().await {
|
|
||||||
error!("Error during shutdown: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the service
|
|
||||||
info!("Starting Sensor Service components...");
|
info!("Starting Sensor Service components...");
|
||||||
if let Err(e) = service.start().await {
|
service.start().await?;
|
||||||
error!("Sensor Service error: {}", e);
|
|
||||||
return Err(e);
|
info!("Attune Sensor Service is ready");
|
||||||
|
|
||||||
|
// Setup signal handlers for graceful shutdown
|
||||||
|
let mut sigint = signal(SignalKind::interrupt())?;
|
||||||
|
let mut sigterm = signal(SignalKind::terminate())?;
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = sigint.recv() => {
|
||||||
|
info!("Received SIGINT signal");
|
||||||
|
}
|
||||||
|
_ = sigterm.recv() => {
|
||||||
|
info!("Received SIGTERM signal");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Sensor Service has shut down gracefully");
|
info!("Shutting down gracefully...");
|
||||||
|
|
||||||
|
// Stop the service: deregister worker, stop sensors, clean up connections
|
||||||
|
if let Err(e) = service.stop().await {
|
||||||
|
error!("Error during shutdown: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Attune Sensor Service shutdown complete");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use serde_json::Value as JsonValue;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::sensor_manager::SensorManager;
|
use crate::sensor_manager::SensorManager;
|
||||||
@@ -22,6 +23,7 @@ pub struct RuleLifecycleListener {
|
|||||||
connection: Connection,
|
connection: Connection,
|
||||||
sensor_manager: Arc<SensorManager>,
|
sensor_manager: Arc<SensorManager>,
|
||||||
consumer: Arc<RwLock<Option<Consumer>>>,
|
consumer: Arc<RwLock<Option<Consumer>>>,
|
||||||
|
task_handle: RwLock<Option<JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuleLifecycleListener {
|
impl RuleLifecycleListener {
|
||||||
@@ -32,6 +34,7 @@ impl RuleLifecycleListener {
|
|||||||
connection,
|
connection,
|
||||||
sensor_manager,
|
sensor_manager,
|
||||||
consumer: Arc::new(RwLock::new(None)),
|
consumer: Arc::new(RwLock::new(None)),
|
||||||
|
task_handle: RwLock::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,19 +91,20 @@ impl RuleLifecycleListener {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store consumer
|
// Store consumer reference (for cleanup on drop)
|
||||||
*self.consumer.write().await = Some(consumer);
|
*self.consumer.write().await = Some(consumer);
|
||||||
|
|
||||||
// Clone self for async handler
|
// Clone references for the spawned task
|
||||||
let db = self.db.clone();
|
let db = self.db.clone();
|
||||||
let sensor_manager = self.sensor_manager.clone();
|
let sensor_manager = self.sensor_manager.clone();
|
||||||
let consumer_ref = self.consumer.clone();
|
let consumer_ref = self.consumer.clone();
|
||||||
|
|
||||||
// Start consuming messages
|
// Start consuming messages in a background task.
|
||||||
tokio::spawn(async move {
|
// Take the consumer out of the Arc<RwLock> so we don't hold the read lock
|
||||||
// Get consumer from the Arc<RwLock<Option<Consumer>>>
|
// for the entire duration of consume_with_handler (which would deadlock stop()).
|
||||||
let consumer_guard = consumer_ref.read().await;
|
let handle = tokio::spawn(async move {
|
||||||
if let Some(consumer) = consumer_guard.as_ref() {
|
let consumer = consumer_ref.write().await.take();
|
||||||
|
if let Some(consumer) = consumer {
|
||||||
let result = consumer
|
let result = consumer
|
||||||
.consume_with_handler::<JsonValue, _, _>(move |envelope| {
|
.consume_with_handler::<JsonValue, _, _>(move |envelope| {
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
@@ -129,6 +133,8 @@ impl RuleLifecycleListener {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
*self.task_handle.write().await = Some(handle);
|
||||||
|
|
||||||
info!("Rule lifecycle listener started");
|
info!("Rule lifecycle listener started");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -138,8 +144,15 @@ impl RuleLifecycleListener {
|
|||||||
pub async fn stop(&self) -> Result<()> {
|
pub async fn stop(&self) -> Result<()> {
|
||||||
info!("Stopping rule lifecycle listener");
|
info!("Stopping rule lifecycle listener");
|
||||||
|
|
||||||
|
// Abort the consumer task first — this ends the consume_with_handler loop
|
||||||
|
// and drops the Consumer (and its channel) inside the task.
|
||||||
|
if let Some(handle) = self.task_handle.write().await.take() {
|
||||||
|
handle.abort();
|
||||||
|
let _ = handle.await; // wait for abort to complete
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any consumer that wasn't taken by the task (e.g. if task never started)
|
||||||
if let Some(consumer) = self.consumer.write().await.take() {
|
if let Some(consumer) = self.consumer.write().await.take() {
|
||||||
// Consumer will be dropped and connection closed
|
|
||||||
drop(consumer);
|
drop(consumer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
//!
|
//!
|
||||||
//! Main service orchestrator that coordinates sensor management
|
//! Main service orchestrator that coordinates sensor management
|
||||||
//! and rule lifecycle listening.
|
//! and rule lifecycle listening.
|
||||||
|
//!
|
||||||
|
//! Shutdown follows the same pattern as the worker service:
|
||||||
|
//! 1. Deregister worker (mark inactive, stop receiving new work)
|
||||||
|
//! 2. Stop heartbeat
|
||||||
|
//! 3. Stop sensor processes with configurable timeout
|
||||||
|
//! 4. Close MQ and DB connections
|
||||||
|
|
||||||
use crate::rule_lifecycle_listener::RuleLifecycleListener;
|
use crate::rule_lifecycle_listener::RuleLifecycleListener;
|
||||||
use crate::sensor_manager::SensorManager;
|
use crate::sensor_manager::SensorManager;
|
||||||
@@ -12,6 +18,7 @@ use attune_common::db::Database;
|
|||||||
use attune_common::mq::MessageQueue;
|
use attune_common::mq::MessageQueue;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
@@ -29,7 +36,7 @@ struct SensorServiceInner {
|
|||||||
rule_lifecycle_listener: Arc<RuleLifecycleListener>,
|
rule_lifecycle_listener: Arc<RuleLifecycleListener>,
|
||||||
sensor_worker_registration: Arc<RwLock<SensorWorkerRegistration>>,
|
sensor_worker_registration: Arc<RwLock<SensorWorkerRegistration>>,
|
||||||
heartbeat_interval: u64,
|
heartbeat_interval: u64,
|
||||||
running: Arc<RwLock<bool>>,
|
heartbeat_running: Arc<RwLock<bool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SensorService {
|
impl SensorService {
|
||||||
@@ -112,18 +119,18 @@ impl SensorService {
|
|||||||
rule_lifecycle_listener,
|
rule_lifecycle_listener,
|
||||||
sensor_worker_registration: Arc::new(RwLock::new(sensor_worker_registration)),
|
sensor_worker_registration: Arc::new(RwLock::new(sensor_worker_registration)),
|
||||||
heartbeat_interval,
|
heartbeat_interval,
|
||||||
running: Arc::new(RwLock::new(false)),
|
heartbeat_running: Arc::new(RwLock::new(false)),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the sensor service
|
/// Start the sensor service
|
||||||
|
///
|
||||||
|
/// Spawns background tasks (heartbeat, rule listener, sensor manager) and returns.
|
||||||
|
/// The caller is responsible for blocking on shutdown signals and calling `stop()`.
|
||||||
pub async fn start(&self) -> Result<()> {
|
pub async fn start(&self) -> Result<()> {
|
||||||
info!("Starting Sensor Service");
|
info!("Starting Sensor Service");
|
||||||
|
|
||||||
// Mark as running
|
|
||||||
*self.inner.running.write().await = true;
|
|
||||||
|
|
||||||
// Register sensor worker
|
// Register sensor worker
|
||||||
info!("Registering sensor worker...");
|
info!("Registering sensor worker...");
|
||||||
let worker_id = self
|
let worker_id = self
|
||||||
@@ -152,37 +159,48 @@ impl SensorService {
|
|||||||
info!("Sensor manager started");
|
info!("Sensor manager started");
|
||||||
|
|
||||||
// Start heartbeat loop
|
// Start heartbeat loop
|
||||||
|
*self.inner.heartbeat_running.write().await = true;
|
||||||
|
|
||||||
let registration = self.inner.sensor_worker_registration.clone();
|
let registration = self.inner.sensor_worker_registration.clone();
|
||||||
let heartbeat_interval = self.inner.heartbeat_interval;
|
let heartbeat_interval = self.inner.heartbeat_interval;
|
||||||
let running = self.inner.running.clone();
|
let heartbeat_running = self.inner.heartbeat_running.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while *running.read().await {
|
let mut ticker = tokio::time::interval(Duration::from_secs(heartbeat_interval));
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(heartbeat_interval)).await;
|
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
|
||||||
|
if !*heartbeat_running.read().await {
|
||||||
|
info!("Heartbeat loop stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(e) = registration.read().await.heartbeat().await {
|
if let Err(e) = registration.read().await.heartbeat().await {
|
||||||
error!("Failed to send sensor worker heartbeat: {}", e);
|
error!("Failed to send sensor worker heartbeat: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!("Heartbeat loop stopped");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait until stopped
|
info!("Sensor Service started successfully");
|
||||||
while *self.inner.running.read().await {
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Sensor Service stopped");
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the sensor service
|
/// Stop the sensor service gracefully
|
||||||
|
///
|
||||||
|
/// Shutdown order (mirrors worker service pattern):
|
||||||
|
/// 1. Deregister worker (mark inactive to stop being scheduled for new work)
|
||||||
|
/// 2. Stop heartbeat
|
||||||
|
/// 3. Stop sensor processes with timeout
|
||||||
|
/// 4. Stop rule lifecycle listener
|
||||||
|
/// 5. Close MQ and DB connections
|
||||||
pub async fn stop(&self) -> Result<()> {
|
pub async fn stop(&self) -> Result<()> {
|
||||||
info!("Stopping Sensor Service");
|
info!("Stopping Sensor Service - initiating graceful shutdown");
|
||||||
|
|
||||||
// Mark as not running
|
// 1. Deregister sensor worker first to stop receiving new work
|
||||||
*self.inner.running.write().await = false;
|
info!("Marking sensor worker as inactive to stop receiving new work");
|
||||||
|
|
||||||
// Deregister sensor worker
|
|
||||||
info!("Deregistering sensor worker...");
|
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.inner
|
.inner
|
||||||
.sensor_worker_registration
|
.sensor_worker_registration
|
||||||
@@ -194,25 +212,51 @@ impl SensorService {
|
|||||||
error!("Failed to deregister sensor worker: {}", e);
|
error!("Failed to deregister sensor worker: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop rule lifecycle listener
|
// 2. Stop heartbeat
|
||||||
|
info!("Stopping heartbeat updates");
|
||||||
|
*self.inner.heartbeat_running.write().await = false;
|
||||||
|
|
||||||
|
// Wait a bit for heartbeat loop to notice the flag
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
// 3. Stop sensor processes with timeout
|
||||||
|
let shutdown_timeout = self
|
||||||
|
.inner
|
||||||
|
.config
|
||||||
|
.sensor
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.shutdown_timeout)
|
||||||
|
.unwrap_or(30);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Waiting up to {} seconds for sensor processes to stop",
|
||||||
|
shutdown_timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
let sensor_manager = self.inner.sensor_manager.clone();
|
||||||
|
let timeout_duration = Duration::from_secs(shutdown_timeout);
|
||||||
|
match tokio::time::timeout(timeout_duration, sensor_manager.stop()).await {
|
||||||
|
Ok(Ok(_)) => info!("All sensor processes stopped"),
|
||||||
|
Ok(Err(e)) => error!("Error stopping sensor processes: {}", e),
|
||||||
|
Err(_) => warn!(
|
||||||
|
"Shutdown timeout reached ({} seconds) - some sensor processes may have been interrupted",
|
||||||
|
shutdown_timeout
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Stop rule lifecycle listener
|
||||||
info!("Stopping rule lifecycle listener...");
|
info!("Stopping rule lifecycle listener...");
|
||||||
if let Err(e) = self.inner.rule_lifecycle_listener.stop().await {
|
if let Err(e) = self.inner.rule_lifecycle_listener.stop().await {
|
||||||
error!("Failed to stop rule lifecycle listener: {}", e);
|
error!("Failed to stop rule lifecycle listener: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop sensor manager
|
// 5. Close message queue connection
|
||||||
info!("Stopping sensor manager...");
|
|
||||||
if let Err(e) = self.inner.sensor_manager.stop().await {
|
|
||||||
error!("Failed to stop sensor manager: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close message queue connection
|
|
||||||
info!("Closing message queue connection...");
|
info!("Closing message queue connection...");
|
||||||
if let Err(e) = self.inner.mq.close().await {
|
if let Err(e) = self.inner.mq.close().await {
|
||||||
warn!("Error closing message queue: {}", e);
|
warn!("Error closing message queue: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close database connection
|
// 6. Close database connection
|
||||||
info!("Closing database connection...");
|
info!("Closing database connection...");
|
||||||
self.inner.db.close().await;
|
self.inner.db.close().await;
|
||||||
|
|
||||||
@@ -221,11 +265,6 @@ impl SensorService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if service is running
|
|
||||||
pub async fn is_running(&self) -> bool {
|
|
||||||
*self.inner.running.read().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get database pool
|
/// Get database pool
|
||||||
pub fn db(&self) -> &PgPool {
|
pub fn db(&self) -> &PgPool {
|
||||||
&self.inner.db
|
&self.inner.db
|
||||||
@@ -243,11 +282,6 @@ impl SensorService {
|
|||||||
|
|
||||||
/// Get health status
|
/// Get health status
|
||||||
pub async fn health_check(&self) -> HealthStatus {
|
pub async fn health_check(&self) -> HealthStatus {
|
||||||
// Check if service is running
|
|
||||||
if !*self.inner.running.read().await {
|
|
||||||
return HealthStatus::Unhealthy("Service not running".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check database connection
|
// Check database connection
|
||||||
if let Err(e) = sqlx::query("SELECT 1").execute(&self.inner.db).await {
|
if let Err(e) = sqlx::query("SELECT 1").execute(&self.inner.db).await {
|
||||||
return HealthStatus::Unhealthy(format!("Database connection failed: {}", e));
|
return HealthStatus::Unhealthy(format!("Database connection failed: {}", e));
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ impl Runtime for LocalRuntime {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::runtime::{ParameterDelivery, ParameterFormat};
|
use crate::runtime::{OutputFormat, ParameterDelivery, ParameterFormat};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -339,13 +339,15 @@ if __name__ == '__main__':
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
OutputFormat::Json => {
|
OutputFormat::Json => {
|
||||||
// Try to parse last line of stdout as JSON
|
// Try to parse full stdout as JSON first (handles multi-line JSON),
|
||||||
stdout_result
|
// then fall back to last line only (for scripts that log before output)
|
||||||
.content
|
let trimmed = stdout_result.content.trim();
|
||||||
.trim()
|
serde_json::from_str(trimmed).ok().or_else(|| {
|
||||||
.lines()
|
trimmed
|
||||||
.last()
|
.lines()
|
||||||
.and_then(|line| serde_json::from_str(line).ok())
|
.last()
|
||||||
|
.and_then(|line| serde_json::from_str(line).ok())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
OutputFormat::Yaml => {
|
OutputFormat::Yaml => {
|
||||||
// Try to parse stdout as YAML
|
// Try to parse stdout as YAML
|
||||||
|
|||||||
@@ -208,13 +208,15 @@ impl ShellRuntime {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
OutputFormat::Json => {
|
OutputFormat::Json => {
|
||||||
// Try to parse last line of stdout as JSON
|
// Try to parse full stdout as JSON first (handles multi-line JSON),
|
||||||
stdout_result
|
// then fall back to last line only (for scripts that log before output)
|
||||||
.content
|
let trimmed = stdout_result.content.trim();
|
||||||
.trim()
|
serde_json::from_str(trimmed).ok().or_else(|| {
|
||||||
.lines()
|
trimmed
|
||||||
.last()
|
.lines()
|
||||||
.and_then(|line| serde_json::from_str(line).ok())
|
.last()
|
||||||
|
.and_then(|line| serde_json::from_str(line).ok())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
OutputFormat::Yaml => {
|
OutputFormat::Yaml => {
|
||||||
// Try to parse stdout as YAML
|
// Try to parse stdout as YAML
|
||||||
@@ -823,4 +825,97 @@ echo '{"id": 3, "name": "Charlie"}'
|
|||||||
assert_eq!(items[2]["id"], 3);
|
assert_eq!(items[2]["id"], 3);
|
||||||
assert_eq!(items[2]["name"], "Charlie");
|
assert_eq!(items[2]["name"], "Charlie");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_shell_runtime_multiline_json_output() {
|
||||||
|
// Regression test: scripts that embed pretty-printed JSON (e.g., http_request.sh
|
||||||
|
// embedding a multi-line response body in its "json" field) produce multi-line
|
||||||
|
// stdout. The parser must handle this by trying to parse the full stdout as JSON
|
||||||
|
// before falling back to last-line parsing.
|
||||||
|
let runtime = ShellRuntime::new();
|
||||||
|
|
||||||
|
let context = ExecutionContext {
|
||||||
|
execution_id: 7,
|
||||||
|
action_ref: "test.multiline_json".to_string(),
|
||||||
|
parameters: HashMap::new(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
secrets: HashMap::new(),
|
||||||
|
timeout: Some(10),
|
||||||
|
working_dir: None,
|
||||||
|
entry_point: "shell".to_string(),
|
||||||
|
code: Some(
|
||||||
|
r#"
|
||||||
|
# Simulate http_request.sh output with embedded pretty-printed JSON
|
||||||
|
printf '{"status_code":200,"body":"hello","json":{\n "args": {\n "hello": "world"\n },\n "url": "https://example.com"\n},"success":true}\n'
|
||||||
|
"#
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
code_path: None,
|
||||||
|
runtime_name: Some("shell".to_string()),
|
||||||
|
max_stdout_bytes: 10 * 1024 * 1024,
|
||||||
|
max_stderr_bytes: 10 * 1024 * 1024,
|
||||||
|
parameter_delivery: attune_common::models::ParameterDelivery::default(),
|
||||||
|
parameter_format: attune_common::models::ParameterFormat::default(),
|
||||||
|
output_format: attune_common::models::OutputFormat::Json,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = runtime.execute(context).await.unwrap();
|
||||||
|
assert!(result.is_success());
|
||||||
|
assert_eq!(result.exit_code, 0);
|
||||||
|
|
||||||
|
// Verify result was parsed (not stored as raw stdout)
|
||||||
|
let parsed = result
|
||||||
|
.result
|
||||||
|
.expect("Multi-line JSON should be parsed successfully");
|
||||||
|
assert_eq!(parsed["status_code"], 200);
|
||||||
|
assert_eq!(parsed["success"], true);
|
||||||
|
assert_eq!(parsed["json"]["args"]["hello"], "world");
|
||||||
|
|
||||||
|
// stdout should be empty when result is successfully parsed
|
||||||
|
assert!(
|
||||||
|
result.stdout.is_empty(),
|
||||||
|
"stdout should be empty when result is parsed, got: {}",
|
||||||
|
result.stdout
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_shell_runtime_json_with_log_prefix() {
|
||||||
|
// Verify last-line fallback still works: scripts that log to stdout
|
||||||
|
// before the final JSON line should still parse correctly.
|
||||||
|
let runtime = ShellRuntime::new();
|
||||||
|
|
||||||
|
let context = ExecutionContext {
|
||||||
|
execution_id: 8,
|
||||||
|
action_ref: "test.json_with_logs".to_string(),
|
||||||
|
parameters: HashMap::new(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
secrets: HashMap::new(),
|
||||||
|
timeout: Some(10),
|
||||||
|
working_dir: None,
|
||||||
|
entry_point: "shell".to_string(),
|
||||||
|
code: Some(
|
||||||
|
r#"
|
||||||
|
echo "Starting action..."
|
||||||
|
echo "Processing data..."
|
||||||
|
echo '{"result": "success", "count": 42}'
|
||||||
|
"#
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
code_path: None,
|
||||||
|
runtime_name: Some("shell".to_string()),
|
||||||
|
max_stdout_bytes: 10 * 1024 * 1024,
|
||||||
|
max_stderr_bytes: 10 * 1024 * 1024,
|
||||||
|
parameter_delivery: attune_common::models::ParameterDelivery::default(),
|
||||||
|
parameter_format: attune_common::models::ParameterFormat::default(),
|
||||||
|
output_format: attune_common::models::OutputFormat::Json,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = runtime.execute(context).await.unwrap();
|
||||||
|
assert!(result.is_success());
|
||||||
|
|
||||||
|
let parsed = result.result.expect("Last-line JSON should be parsed");
|
||||||
|
assert_eq!(parsed["result"], "success");
|
||||||
|
assert_eq!(parsed["count"], 42);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use sqlx::PgPool;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::artifacts::ArtifactManager;
|
use crate::artifacts::ArtifactManager;
|
||||||
@@ -51,6 +52,7 @@ pub struct WorkerService {
|
|||||||
mq_connection: Arc<Connection>,
|
mq_connection: Arc<Connection>,
|
||||||
publisher: Arc<Publisher>,
|
publisher: Arc<Publisher>,
|
||||||
consumer: Option<Arc<Consumer>>,
|
consumer: Option<Arc<Consumer>>,
|
||||||
|
consumer_handle: Option<JoinHandle<()>>,
|
||||||
worker_id: Option<i64>,
|
worker_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +268,7 @@ impl WorkerService {
|
|||||||
mq_connection: Arc::new(mq_connection),
|
mq_connection: Arc::new(mq_connection),
|
||||||
publisher: Arc::new(publisher),
|
publisher: Arc::new(publisher),
|
||||||
consumer: None,
|
consumer: None,
|
||||||
|
consumer_handle: None,
|
||||||
worker_id: None,
|
worker_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -305,25 +308,35 @@ impl WorkerService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the worker service
|
/// Stop the worker service gracefully
|
||||||
|
///
|
||||||
|
/// Shutdown order (mirrors sensor service pattern):
|
||||||
|
/// 1. Deregister worker (mark inactive to stop receiving new work)
|
||||||
|
/// 2. Stop heartbeat
|
||||||
|
/// 3. Wait for in-flight tasks with timeout
|
||||||
|
/// 4. Close MQ connection
|
||||||
|
/// 5. Close DB connection
|
||||||
pub async fn stop(&mut self) -> Result<()> {
|
pub async fn stop(&mut self) -> Result<()> {
|
||||||
info!("Stopping Worker Service - initiating graceful shutdown");
|
info!("Stopping Worker Service - initiating graceful shutdown");
|
||||||
|
|
||||||
// Mark worker as inactive first to stop receiving new tasks
|
// 1. Mark worker as inactive first to stop receiving new tasks
|
||||||
|
// Use if-let instead of ? so shutdown continues even if DB call fails
|
||||||
{
|
{
|
||||||
let reg = self.registration.read().await;
|
let reg = self.registration.read().await;
|
||||||
info!("Marking worker as inactive to stop receiving new tasks");
|
info!("Marking worker as inactive to stop receiving new tasks");
|
||||||
reg.deregister().await?;
|
if let Err(e) = reg.deregister().await {
|
||||||
|
error!("Failed to deregister worker: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop heartbeat
|
// 2. Stop heartbeat
|
||||||
info!("Stopping heartbeat updates");
|
info!("Stopping heartbeat updates");
|
||||||
self.heartbeat.stop().await;
|
self.heartbeat.stop().await;
|
||||||
|
|
||||||
// Wait a bit for heartbeat to stop
|
// Wait a bit for heartbeat loop to notice the flag
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
// Wait for in-flight tasks to complete (with timeout)
|
// 3. Wait for in-flight tasks to complete (with timeout)
|
||||||
let shutdown_timeout = self
|
let shutdown_timeout = self
|
||||||
.config
|
.config
|
||||||
.worker
|
.worker
|
||||||
@@ -342,6 +355,23 @@ impl WorkerService {
|
|||||||
Err(_) => warn!("Shutdown timeout reached - some tasks may have been interrupted"),
|
Err(_) => warn!("Shutdown timeout reached - some tasks may have been interrupted"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Abort consumer task and close message queue connection
|
||||||
|
if let Some(handle) = self.consumer_handle.take() {
|
||||||
|
info!("Stopping consumer task...");
|
||||||
|
handle.abort();
|
||||||
|
// Wait briefly for the task to finish
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Closing message queue connection...");
|
||||||
|
if let Err(e) = self.mq_connection.close().await {
|
||||||
|
warn!("Error closing message queue: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Close database connection
|
||||||
|
info!("Closing database connection...");
|
||||||
|
self.db_pool.close().await;
|
||||||
|
|
||||||
info!("Worker Service stopped");
|
info!("Worker Service stopped");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -364,6 +394,9 @@ impl WorkerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start consuming execution.scheduled messages
|
/// Start consuming execution.scheduled messages
|
||||||
|
///
|
||||||
|
/// Spawns the consumer loop as a background task so that `start()` returns
|
||||||
|
/// immediately, allowing the caller to set up signal handlers.
|
||||||
async fn start_execution_consumer(&mut self) -> Result<()> {
|
async fn start_execution_consumer(&mut self) -> Result<()> {
|
||||||
let worker_id = self
|
let worker_id = self
|
||||||
.worker_id
|
.worker_id
|
||||||
@@ -375,48 +408,63 @@ impl WorkerService {
|
|||||||
info!("Starting consumer for worker queue: {}", queue_name);
|
info!("Starting consumer for worker queue: {}", queue_name);
|
||||||
|
|
||||||
// Create consumer
|
// Create consumer
|
||||||
let consumer = Consumer::new(
|
let consumer = Arc::new(
|
||||||
&self.mq_connection,
|
Consumer::new(
|
||||||
ConsumerConfig {
|
&self.mq_connection,
|
||||||
queue: queue_name.clone(),
|
ConsumerConfig {
|
||||||
tag: format!("worker-{}", worker_id),
|
queue: queue_name.clone(),
|
||||||
prefetch_count: 10,
|
tag: format!("worker-{}", worker_id),
|
||||||
auto_ack: false,
|
prefetch_count: 10,
|
||||||
exclusive: false,
|
auto_ack: false,
|
||||||
},
|
exclusive: false,
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| Error::Internal(format!("Failed to create consumer: {}", e)))?;
|
|
||||||
|
|
||||||
info!("Consumer started for queue: {}", queue_name);
|
|
||||||
|
|
||||||
info!("Message queue consumer initialized");
|
|
||||||
|
|
||||||
// Clone Arc references for the handler
|
|
||||||
let executor = self.executor.clone();
|
|
||||||
let publisher = self.publisher.clone();
|
|
||||||
let db_pool = self.db_pool.clone();
|
|
||||||
|
|
||||||
// Consume messages with handler
|
|
||||||
consumer
|
|
||||||
.consume_with_handler(
|
|
||||||
move |envelope: MessageEnvelope<ExecutionScheduledPayload>| {
|
|
||||||
let executor = executor.clone();
|
|
||||||
let publisher = publisher.clone();
|
|
||||||
let db_pool = db_pool.clone();
|
|
||||||
|
|
||||||
async move {
|
|
||||||
Self::handle_execution_scheduled(executor, publisher, db_pool, envelope)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Execution handler error: {}", e).into())
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::Internal(format!("Failed to start consumer: {}", e)))?;
|
.map_err(|e| Error::Internal(format!("Failed to create consumer: {}", e)))?,
|
||||||
|
);
|
||||||
|
|
||||||
// Store consumer reference
|
info!("Consumer created for queue: {}", queue_name);
|
||||||
self.consumer = Some(Arc::new(consumer));
|
|
||||||
|
// Clone Arc references for the spawned task
|
||||||
|
let executor = self.executor.clone();
|
||||||
|
let publisher = self.publisher.clone();
|
||||||
|
let db_pool = self.db_pool.clone();
|
||||||
|
let consumer_for_task = consumer.clone();
|
||||||
|
let queue_name_for_log = queue_name.clone();
|
||||||
|
|
||||||
|
// Spawn the consumer loop as a background task so start() can return
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
info!("Consumer loop started for queue '{}'", queue_name_for_log);
|
||||||
|
let result = consumer_for_task
|
||||||
|
.consume_with_handler(
|
||||||
|
move |envelope: MessageEnvelope<ExecutionScheduledPayload>| {
|
||||||
|
let executor = executor.clone();
|
||||||
|
let publisher = publisher.clone();
|
||||||
|
let db_pool = db_pool.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
Self::handle_execution_scheduled(executor, publisher, db_pool, envelope)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Execution handler error: {}", e).into())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => info!("Consumer loop for queue '{}' ended", queue_name_for_log),
|
||||||
|
Err(e) => error!(
|
||||||
|
"Consumer loop for queue '{}' failed: {}",
|
||||||
|
queue_name_for_log, e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store consumer reference and task handle
|
||||||
|
self.consumer = Some(consumer);
|
||||||
|
self.consumer_handle = Some(handle);
|
||||||
|
|
||||||
|
info!("Message queue consumer initialized");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ services:
|
|||||||
container_name: attune-api
|
container_name: attune-api
|
||||||
environment:
|
environment:
|
||||||
RUST_LOG: info
|
RUST_LOG: info
|
||||||
ATTUNE_CONFIG: /opt/attune/config.docker.yaml
|
ATTUNE_CONFIG: /opt/attune/config.yaml
|
||||||
# Security - MUST set these in production via .env file
|
# Security - MUST set these in production via .env file
|
||||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||||
@@ -221,7 +221,7 @@ services:
|
|||||||
container_name: attune-executor
|
container_name: attune-executor
|
||||||
environment:
|
environment:
|
||||||
RUST_LOG: info
|
RUST_LOG: info
|
||||||
ATTUNE_CONFIG: /opt/attune/config.docker.yaml
|
ATTUNE_CONFIG: /opt/attune/config.yaml
|
||||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||||
@@ -246,7 +246,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pgrep -f attune-service || exit 1"]
|
test: ["CMD-SHELL", "kill -0 1 || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -256,10 +256,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Worker Services (Multiple variants with different runtime capabilities)
|
# Workers
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Base worker - Shell commands only
|
|
||||||
worker-shell:
|
worker-shell:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -271,7 +269,7 @@ services:
|
|||||||
stop_grace_period: 45s
|
stop_grace_period: 45s
|
||||||
environment:
|
environment:
|
||||||
RUST_LOG: info
|
RUST_LOG: info
|
||||||
ATTUNE_CONFIG: /opt/attune/config.docker.yaml
|
ATTUNE_CONFIG: /opt/attune/config.yaml
|
||||||
ATTUNE_WORKER_RUNTIMES: shell
|
ATTUNE_WORKER_RUNTIMES: shell
|
||||||
ATTUNE_WORKER_TYPE: container
|
ATTUNE_WORKER_TYPE: container
|
||||||
ATTUNE_WORKER_NAME: worker-shell-01
|
ATTUNE_WORKER_NAME: worker-shell-01
|
||||||
@@ -316,7 +314,7 @@ services:
|
|||||||
stop_grace_period: 45s
|
stop_grace_period: 45s
|
||||||
environment:
|
environment:
|
||||||
RUST_LOG: info
|
RUST_LOG: info
|
||||||
ATTUNE_CONFIG: /opt/attune/config.docker.yaml
|
ATTUNE_CONFIG: /opt/attune/config.yaml
|
||||||
ATTUNE_WORKER_RUNTIMES: shell,python
|
ATTUNE_WORKER_RUNTIMES: shell,python
|
||||||
ATTUNE_WORKER_TYPE: container
|
ATTUNE_WORKER_TYPE: container
|
||||||
ATTUNE_WORKER_NAME: worker-python-01
|
ATTUNE_WORKER_NAME: worker-python-01
|
||||||
@@ -361,7 +359,7 @@ services:
|
|||||||
stop_grace_period: 45s
|
stop_grace_period: 45s
|
||||||
environment:
|
environment:
|
||||||
RUST_LOG: info
|
RUST_LOG: info
|
||||||
ATTUNE_CONFIG: /opt/attune/config.docker.yaml
|
ATTUNE_CONFIG: /opt/attune/config.yaml
|
||||||
ATTUNE_WORKER_RUNTIMES: shell,node
|
ATTUNE_WORKER_RUNTIMES: shell,node
|
||||||
ATTUNE_WORKER_TYPE: container
|
ATTUNE_WORKER_TYPE: container
|
||||||
ATTUNE_WORKER_NAME: worker-node-01
|
ATTUNE_WORKER_NAME: worker-node-01
|
||||||
@@ -406,7 +404,7 @@ services:
|
|||||||
stop_grace_period: 45s
|
stop_grace_period: 45s
|
||||||
environment:
|
environment:
|
||||||
RUST_LOG: info
|
RUST_LOG: info
|
||||||
ATTUNE_CONFIG: /opt/attune/config.docker.yaml
|
ATTUNE_CONFIG: /opt/attune/config.yaml
|
||||||
ATTUNE_WORKER_RUNTIMES: shell,python,node,native
|
ATTUNE_WORKER_RUNTIMES: shell,python,node,native
|
||||||
ATTUNE_WORKER_TYPE: container
|
ATTUNE_WORKER_TYPE: container
|
||||||
ATTUNE_WORKER_NAME: worker-full-01
|
ATTUNE_WORKER_NAME: worker-full-01
|
||||||
@@ -447,9 +445,10 @@ services:
|
|||||||
SERVICE: sensor
|
SERVICE: sensor
|
||||||
BUILDKIT_INLINE_CACHE: 1
|
BUILDKIT_INLINE_CACHE: 1
|
||||||
container_name: attune-sensor
|
container_name: attune-sensor
|
||||||
|
stop_grace_period: 45s
|
||||||
environment:
|
environment:
|
||||||
RUST_LOG: debug
|
RUST_LOG: debug
|
||||||
ATTUNE_CONFIG: /opt/attune/config.docker.yaml
|
ATTUNE_CONFIG: /opt/attune/config.yaml
|
||||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||||
@@ -475,7 +474,7 @@ services:
|
|||||||
rabbitmq:
|
rabbitmq:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pgrep -f attune-service || exit 1"]
|
test: ["CMD-SHELL", "kill -0 1 || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -494,7 +493,7 @@ services:
|
|||||||
container_name: attune-notifier
|
container_name: attune-notifier
|
||||||
environment:
|
environment:
|
||||||
RUST_LOG: info
|
RUST_LOG: info
|
||||||
ATTUNE_CONFIG: /opt/attune/config.docker.yaml
|
ATTUNE_CONFIG: /opt/attune/config.yaml
|
||||||
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
|
||||||
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
|
||||||
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
|
||||||
@@ -512,7 +511,7 @@ services:
|
|||||||
rabbitmq:
|
rabbitmq:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pgrep -f attune-service || exit 1"]
|
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -524,7 +523,6 @@ services:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Web UI
|
# Web UI
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -537,8 +535,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
api:
|
||||||
- notifier
|
condition: service_healthy
|
||||||
|
notifier:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -109,9 +109,9 @@ WORKDIR /opt/attune
|
|||||||
# Note: We copy from /build/attune-service-binary because the cache mount is not available in COPY
|
# Note: We copy from /build/attune-service-binary because the cache mount is not available in COPY
|
||||||
COPY --from=builder /build/attune-service-binary /usr/local/bin/attune-service
|
COPY --from=builder /build/attune-service-binary /usr/local/bin/attune-service
|
||||||
|
|
||||||
# Copy configuration files
|
# Copy configuration for Docker Compose development
|
||||||
COPY config.production.yaml ./config.yaml
|
# Production: mount config files as a volume instead of baking them into the image
|
||||||
COPY config.docker.yaml ./config.docker.yaml
|
COPY config.docker.yaml ./config.yaml
|
||||||
|
|
||||||
# Copy migrations for services that need them
|
# Copy migrations for services that need them
|
||||||
COPY migrations/ ./migrations/
|
COPY migrations/ ./migrations/
|
||||||
@@ -132,7 +132,7 @@ USER attune
|
|||||||
|
|
||||||
# Environment variables (can be overridden at runtime)
|
# Environment variables (can be overridden at runtime)
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
ENV ATTUNE_CONFIG=/opt/attune/config.docker.yaml
|
ENV ATTUNE_CONFIG=/opt/attune/config.yaml
|
||||||
|
|
||||||
# Health check (will be overridden per service in docker-compose)
|
# Health check (will be overridden per service in docker-compose)
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
|||||||
@@ -123,6 +123,17 @@ COPY migrations/ ./migrations/
|
|||||||
# Copy the common crate (almost all services depend on this)
|
# Copy the common crate (almost all services depend on this)
|
||||||
COPY crates/common/ ./crates/common/
|
COPY crates/common/ ./crates/common/
|
||||||
|
|
||||||
|
# Build the specified service
|
||||||
|
# The cargo registry and git cache are pre-populated from the planner stage
|
||||||
|
# Only the actual compilation happens here
|
||||||
|
# - registry/git use sharing=shared (concurrent builds of different services are safe)
|
||||||
|
# - target uses service-specific cache ID (each service compiles different crates)
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git,sharing=shared \
|
||||||
|
--mount=type=cache,target=/build/target,sharing=locked \
|
||||||
|
cargo build --release --lib -p attune-common
|
||||||
|
|
||||||
|
|
||||||
# Build argument to specify which service to build
|
# Build argument to specify which service to build
|
||||||
ARG SERVICE=api
|
ARG SERVICE=api
|
||||||
|
|
||||||
@@ -137,7 +148,7 @@ COPY crates/${SERVICE}/ ./crates/${SERVICE}/
|
|||||||
# - target uses service-specific cache ID (each service compiles different crates)
|
# - target uses service-specific cache ID (each service compiles different crates)
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=shared \
|
--mount=type=cache,target=/usr/local/cargo/git,sharing=shared \
|
||||||
--mount=type=cache,target=/build/target,id=target-builder-${SERVICE} \
|
--mount=type=cache,target=/build/target,sharing=shared \
|
||||||
cargo build --release --bin attune-${SERVICE} && \
|
cargo build --release --bin attune-${SERVICE} && \
|
||||||
cp /build/target/release/attune-${SERVICE} /build/attune-service-binary
|
cp /build/target/release/attune-${SERVICE} /build/attune-service-binary
|
||||||
|
|
||||||
@@ -164,9 +175,9 @@ WORKDIR /opt/attune
|
|||||||
# Copy the service binary from builder
|
# Copy the service binary from builder
|
||||||
COPY --from=builder /build/attune-service-binary /usr/local/bin/attune-service
|
COPY --from=builder /build/attune-service-binary /usr/local/bin/attune-service
|
||||||
|
|
||||||
# Copy configuration files
|
# Copy configuration file for Docker Compose development
|
||||||
COPY config.production.yaml ./config.yaml
|
# In production, mount config files as a volume instead of baking them into the image
|
||||||
COPY config.docker.yaml ./config.docker.yaml
|
COPY config.docker.yaml ./config.yaml
|
||||||
|
|
||||||
# Copy migrations for services that need them
|
# Copy migrations for services that need them
|
||||||
COPY migrations/ ./migrations/
|
COPY migrations/ ./migrations/
|
||||||
@@ -184,7 +195,7 @@ USER attune
|
|||||||
|
|
||||||
# Environment variables (can be overridden at runtime)
|
# Environment variables (can be overridden at runtime)
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
ENV ATTUNE_CONFIG=/opt/attune/config.docker.yaml
|
ENV ATTUNE_CONFIG=/opt/attune/config.yaml
|
||||||
|
|
||||||
# Health check (will be overridden per service in docker-compose)
|
# Health check (will be overridden per service in docker-compose)
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
|||||||
@@ -26,9 +26,18 @@ server {
|
|||||||
add_header Content-Type text/plain;
|
add_header Content-Type text/plain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Use Docker's embedded DNS resolver so that proxy_pass with variables
|
||||||
|
# resolves hostnames at request time, not config load time.
|
||||||
|
# This prevents nginx from crashing if backends aren't ready yet.
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
set $api_upstream http://api:8080;
|
||||||
|
set $notifier_upstream http://notifier:8081;
|
||||||
|
|
||||||
# Auth proxy - forward auth requests to backend
|
# Auth proxy - forward auth requests to backend
|
||||||
|
# With variable proxy_pass (no URI path), the full original request URI
|
||||||
|
# (e.g. /auth/login) is passed through to the backend as-is.
|
||||||
location /auth/ {
|
location /auth/ {
|
||||||
proxy_pass http://api:8080/auth/;
|
proxy_pass $api_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
@@ -45,8 +54,10 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# API proxy - forward API requests to backend (preserves /api prefix)
|
# API proxy - forward API requests to backend (preserves /api prefix)
|
||||||
|
# With variable proxy_pass (no URI path), the full original request URI
|
||||||
|
# (e.g. /api/packs?page=1) is passed through to the backend as-is.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8080/api/;
|
proxy_pass $api_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
@@ -63,8 +74,11 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# WebSocket proxy for notifier service
|
# WebSocket proxy for notifier service
|
||||||
|
# Strip the /ws/ prefix before proxying (notifier expects paths at root).
|
||||||
|
# e.g. /ws/events → /events
|
||||||
location /ws/ {
|
location /ws/ {
|
||||||
proxy_pass http://notifier:8081/;
|
rewrite ^/ws/(.*) /$1 break;
|
||||||
|
proxy_pass $notifier_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "Upgrade";
|
proxy_set_header Connection "Upgrade";
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ query_params_file=$(mktemp)
|
|||||||
body_file=""
|
body_file=""
|
||||||
temp_headers=$(mktemp)
|
temp_headers=$(mktemp)
|
||||||
curl_output=$(mktemp)
|
curl_output=$(mktemp)
|
||||||
|
write_out_file=$(mktemp)
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -f "$headers_file" "$query_params_file" "$temp_headers" "$curl_output"
|
local exit_code=$?
|
||||||
|
rm -f "$headers_file" "$query_params_file" "$temp_headers" "$curl_output" "$write_out_file"
|
||||||
[ -n "$body_file" ] && [ -f "$body_file" ] && rm -f "$body_file"
|
[ -n "$body_file" ] && [ -f "$body_file" ] && rm -f "$body_file"
|
||||||
|
return "$exit_code"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
@@ -119,7 +122,10 @@ curl_args=$(mktemp)
|
|||||||
{
|
{
|
||||||
printf -- '-X\n%s\n' "$method"
|
printf -- '-X\n%s\n' "$method"
|
||||||
printf -- '-s\n'
|
printf -- '-s\n'
|
||||||
printf -- '-w\n\n%%{http_code}\n%%{url_effective}\n\n'
|
# Use @file for -w to avoid xargs escape interpretation issues
|
||||||
|
# curl's @file mode requires literal \n (two chars) not actual newlines
|
||||||
|
printf '\\n%%{http_code}\\n%%{url_effective}\\n' > "$write_out_file"
|
||||||
|
printf -- '-w\n@%s\n' "$write_out_file"
|
||||||
printf -- '--max-time\n%s\n' "$timeout"
|
printf -- '--max-time\n%s\n' "$timeout"
|
||||||
printf -- '--connect-timeout\n10\n'
|
printf -- '--connect-timeout\n10\n'
|
||||||
printf -- '--dump-header\n%s\n' "$temp_headers"
|
printf -- '--dump-header\n%s\n' "$temp_headers"
|
||||||
@@ -241,7 +247,13 @@ if [ -n "$body_output" ]; then
|
|||||||
case "$first_char" in
|
case "$first_char" in
|
||||||
'{'|'[')
|
'{'|'[')
|
||||||
case "$last_char" in
|
case "$last_char" in
|
||||||
'}'|']') json_parsed="$body_output" ;;
|
'}'|']')
|
||||||
|
# Compact multi-line JSON to single line to avoid breaking
|
||||||
|
# the worker's last-line JSON parser. In valid JSON, literal
|
||||||
|
# newlines only appear as whitespace outside strings (inside
|
||||||
|
# strings they must be escaped as \n), so tr is safe here.
|
||||||
|
json_parsed=$(printf '%s' "$body_output" | tr '\n' ' ' | tr '\r' ' ')
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
273
web/src/components/common/ExecuteActionModal.tsx
Normal file
273
web/src/components/common/ExecuteActionModal.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { OpenAPI } from "@/api";
|
||||||
|
import { Play, X } from "lucide-react";
|
||||||
|
import ParamSchemaForm, {
|
||||||
|
validateParamSchema,
|
||||||
|
type ParamSchema,
|
||||||
|
} from "@/components/common/ParamSchemaForm";
|
||||||
|
|
||||||
|
interface ExecuteActionModalProps {
|
||||||
|
action: any;
|
||||||
|
onClose: () => void;
|
||||||
|
initialParameters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared modal for executing an action with a dynamic parameter form.
|
||||||
|
*
|
||||||
|
* Used from:
|
||||||
|
* - ActionDetail page (Execute button)
|
||||||
|
* - ExecutionDetailPage (Re-Run button, with initialParameters pre-filled from previous execution config)
|
||||||
|
*/
|
||||||
|
export default function ExecuteActionModal({
|
||||||
|
action,
|
||||||
|
onClose,
|
||||||
|
initialParameters,
|
||||||
|
}: ExecuteActionModalProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const paramSchema: ParamSchema = (action.param_schema as ParamSchema) || {};
|
||||||
|
|
||||||
|
// If initialParameters are provided, use them (stripping out any keys not in the schema)
|
||||||
|
const buildInitialValues = (): Record<string, any> => {
|
||||||
|
if (!initialParameters) return {};
|
||||||
|
const properties = paramSchema.properties || {};
|
||||||
|
const values: Record<string, any> = {};
|
||||||
|
// Include all initial parameters - even those not in the schema
|
||||||
|
// so users can see exactly what was run before
|
||||||
|
for (const [key, value] of Object.entries(initialParameters)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
values[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also fill in defaults for any schema properties not covered
|
||||||
|
for (const [key, param] of Object.entries(properties)) {
|
||||||
|
if (values[key] === undefined && param?.default !== undefined) {
|
||||||
|
values[key] = param.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [parameters, setParameters] = useState<Record<string, any>>(
|
||||||
|
buildInitialValues,
|
||||||
|
);
|
||||||
|
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
|
||||||
|
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>(
|
||||||
|
[{ key: "", value: "" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
const executeAction = useMutation({
|
||||||
|
mutationFn: async (params: {
|
||||||
|
parameters: Record<string, any>;
|
||||||
|
envVars: Array<{ key: string; value: string }>;
|
||||||
|
}) => {
|
||||||
|
const token =
|
||||||
|
typeof OpenAPI.TOKEN === "function"
|
||||||
|
? await OpenAPI.TOKEN({} as any)
|
||||||
|
: OpenAPI.TOKEN;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${OpenAPI.BASE}/api/v1/executions/execute`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action_ref: action.ref,
|
||||||
|
parameters: params.parameters,
|
||||||
|
env_vars: params.envVars
|
||||||
|
.filter((ev) => ev.key.trim() !== "")
|
||||||
|
.reduce(
|
||||||
|
(acc, ev) => {
|
||||||
|
acc[ev.key] = ev.value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || "Failed to execute action");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["executions"] });
|
||||||
|
onClose();
|
||||||
|
if (data?.data?.id) {
|
||||||
|
window.location.href = `/executions/${data.data.id}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const errors = validateParamSchema(paramSchema, parameters);
|
||||||
|
setParamErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecute = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeAction.mutateAsync({ parameters, envVars });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to execute action:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEnvVar = () => {
|
||||||
|
setEnvVars([...envVars, { key: "", value: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEnvVar = (index: number) => {
|
||||||
|
if (envVars.length > 1) {
|
||||||
|
setEnvVars(envVars.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEnvVar = (
|
||||||
|
index: number,
|
||||||
|
field: "key" | "value",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const updated = [...envVars];
|
||||||
|
updated[index][field] = value;
|
||||||
|
setEnvVars(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-xl font-bold">
|
||||||
|
{initialParameters ? "Re-Run Action" : "Execute Action"}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Action:{" "}
|
||||||
|
<span className="font-mono text-gray-900">{action.ref}</span>
|
||||||
|
</p>
|
||||||
|
{action.description && (
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{action.description}</p>
|
||||||
|
)}
|
||||||
|
{initialParameters && (
|
||||||
|
<p className="text-xs text-blue-600 mt-2 bg-blue-50 px-3 py-1.5 rounded">
|
||||||
|
Parameters pre-filled from previous execution. Modify as needed
|
||||||
|
before re-running.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{executeAction.error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
||||||
|
{(executeAction.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Parameters
|
||||||
|
</h4>
|
||||||
|
<ParamSchemaForm
|
||||||
|
schema={paramSchema}
|
||||||
|
values={parameters}
|
||||||
|
onChange={setParameters}
|
||||||
|
errors={paramErrors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Environment Variables
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Optional environment variables for this execution (e.g., DEBUG,
|
||||||
|
LOG_LEVEL)
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{envVars.map((envVar, index) => (
|
||||||
|
<div key={index} className="flex gap-2 items-start">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Key"
|
||||||
|
value={envVar.key}
|
||||||
|
onChange={(e) => updateEnvVar(index, "key", e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Value"
|
||||||
|
value={envVar.value}
|
||||||
|
onChange={(e) => updateEnvVar(index, "value", e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEnvVar(index)}
|
||||||
|
disabled={envVars.length === 1}
|
||||||
|
className="px-3 py-2 text-red-600 hover:text-red-700 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addEnvVar}
|
||||||
|
className="mt-2 text-sm text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
+ Add Environment Variable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={executeAction.isPending}
|
||||||
|
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={executeAction.isPending}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{executeAction.isPending ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||||
|
Executing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
{initialParameters ? "Re-Run" : "Execute"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,12 +3,7 @@ import { useActions, useAction, useDeleteAction } from "@/hooks/useActions";
|
|||||||
import { useExecutions } from "@/hooks/useExecutions";
|
import { useExecutions } from "@/hooks/useExecutions";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { ChevronDown, ChevronRight, Search, X, Play } from "lucide-react";
|
import { ChevronDown, ChevronRight, Search, X, Play } from "lucide-react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import ExecuteActionModal from "@/components/common/ExecuteActionModal";
|
||||||
import { OpenAPI } from "@/api";
|
|
||||||
import ParamSchemaForm, {
|
|
||||||
validateParamSchema,
|
|
||||||
type ParamSchema,
|
|
||||||
} from "@/components/common/ParamSchemaForm";
|
|
||||||
import ErrorDisplay from "@/components/common/ErrorDisplay";
|
import ErrorDisplay from "@/components/common/ErrorDisplay";
|
||||||
|
|
||||||
export default function ActionsPage() {
|
export default function ActionsPage() {
|
||||||
@@ -573,229 +568,3 @@ function ActionDetail({ actionRef }: { actionRef: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExecuteActionModal({
|
|
||||||
action,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
action: any;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Initialize parameters with default values from schema
|
|
||||||
const paramSchema: ParamSchema = (action.param_schema as ParamSchema) || {};
|
|
||||||
|
|
||||||
const [parameters, setParameters] = useState<Record<string, any>>({});
|
|
||||||
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
|
|
||||||
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>(
|
|
||||||
[{ key: "", value: "" }],
|
|
||||||
);
|
|
||||||
|
|
||||||
const executeAction = useMutation({
|
|
||||||
mutationFn: async (params: {
|
|
||||||
parameters: Record<string, any>;
|
|
||||||
envVars: Array<{ key: string; value: string }>;
|
|
||||||
}) => {
|
|
||||||
// Get the token by calling the TOKEN function
|
|
||||||
const token =
|
|
||||||
typeof OpenAPI.TOKEN === "function"
|
|
||||||
? await OpenAPI.TOKEN({} as any)
|
|
||||||
: OpenAPI.TOKEN;
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${OpenAPI.BASE}/api/v1/executions/execute`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
action_ref: action.ref,
|
|
||||||
parameters: params.parameters,
|
|
||||||
env_vars: params.envVars
|
|
||||||
.filter((ev) => ev.key.trim() !== "")
|
|
||||||
.reduce(
|
|
||||||
(acc, ev) => {
|
|
||||||
acc[ev.key] = ev.value;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || "Failed to execute action");
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["executions"] });
|
|
||||||
onClose();
|
|
||||||
// Redirect to execution detail page
|
|
||||||
if (data?.data?.id) {
|
|
||||||
window.location.href = `/executions/${data.data.id}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
const errors = validateParamSchema(paramSchema, parameters);
|
|
||||||
setParamErrors(errors);
|
|
||||||
return Object.keys(errors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExecute = async () => {
|
|
||||||
if (!validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await executeAction.mutateAsync({ parameters, envVars });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to execute action:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addEnvVar = () => {
|
|
||||||
setEnvVars([...envVars, { key: "", value: "" }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeEnvVar = (index: number) => {
|
|
||||||
if (envVars.length > 1) {
|
|
||||||
setEnvVars(envVars.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateEnvVar = (
|
|
||||||
index: number,
|
|
||||||
field: "key" | "value",
|
|
||||||
value: string,
|
|
||||||
) => {
|
|
||||||
const updated = [...envVars];
|
|
||||||
updated[index][field] = value;
|
|
||||||
setEnvVars(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-xl font-bold">Execute Action</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Action:{" "}
|
|
||||||
<span className="font-mono text-gray-900">{action.ref}</span>
|
|
||||||
</p>
|
|
||||||
{action.description && (
|
|
||||||
<p className="text-sm text-gray-600 mt-1">{action.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{executeAction.error && (
|
|
||||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
|
||||||
{(executeAction.error as Error).message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">
|
|
||||||
Parameters
|
|
||||||
</h4>
|
|
||||||
<ParamSchemaForm
|
|
||||||
schema={paramSchema}
|
|
||||||
values={parameters}
|
|
||||||
onChange={setParameters}
|
|
||||||
errors={paramErrors}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">
|
|
||||||
Environment Variables
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
|
||||||
Optional environment variables for this execution (e.g., DEBUG,
|
|
||||||
LOG_LEVEL)
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{envVars.map((envVar, index) => (
|
|
||||||
<div key={index} className="flex gap-2 items-start">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Key"
|
|
||||||
value={envVar.key}
|
|
||||||
onChange={(e) => updateEnvVar(index, "key", e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Value"
|
|
||||||
value={envVar.value}
|
|
||||||
onChange={(e) => updateEnvVar(index, "value", e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeEnvVar(index)}
|
|
||||||
disabled={envVars.length === 1}
|
|
||||||
className="px-3 py-2 text-red-600 hover:text-red-700 disabled:text-gray-300 disabled:cursor-not-allowed"
|
|
||||||
title="Remove"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addEnvVar}
|
|
||||||
className="mt-2 text-sm text-blue-600 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
+ Add Environment Variable
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={executeAction.isPending}
|
|
||||||
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleExecute}
|
|
||||||
disabled={executeAction.isPending}
|
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{executeAction.isPending ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
|
||||||
Executing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
Execute
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useParams, Link } from "react-router-dom";
|
import { useParams, Link } from "react-router-dom";
|
||||||
import { useExecution } from "@/hooks/useExecutions";
|
import { useExecution } from "@/hooks/useExecutions";
|
||||||
|
import { useAction } from "@/hooks/useActions";
|
||||||
import { useExecutionStream } from "@/hooks/useExecutionStream";
|
import { useExecutionStream } from "@/hooks/useExecutionStream";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { ExecutionStatus } from "@/api";
|
import { ExecutionStatus } from "@/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
|
import ExecuteActionModal from "@/components/common/ExecuteActionModal";
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -31,6 +35,11 @@ export default function ExecutionDetailPage() {
|
|||||||
const { data: executionData, isLoading, error } = useExecution(Number(id));
|
const { data: executionData, isLoading, error } = useExecution(Number(id));
|
||||||
const execution = executionData?.data;
|
const execution = executionData?.data;
|
||||||
|
|
||||||
|
// Fetch the action so we can get param_schema for the re-run modal
|
||||||
|
const { data: actionData } = useAction(execution?.action_ref || "");
|
||||||
|
|
||||||
|
const [showRerunModal, setShowRerunModal] = useState(false);
|
||||||
|
|
||||||
// Subscribe to real-time updates for this execution
|
// Subscribe to real-time updates for this execution
|
||||||
const { isConnected } = useExecutionStream({
|
const { isConnected } = useExecutionStream({
|
||||||
executionId: Number(id),
|
executionId: Number(id),
|
||||||
@@ -102,6 +111,19 @@ export default function ExecutionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRerunModal(true)}
|
||||||
|
disabled={!actionData?.data}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
title={
|
||||||
|
!actionData?.data
|
||||||
|
? "Loading action details..."
|
||||||
|
: "Re-run this action with the same parameters"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Re-Run
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 mt-2">
|
<p className="text-gray-600 mt-2">
|
||||||
<Link
|
<Link
|
||||||
@@ -113,6 +135,15 @@ export default function ExecutionDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Re-Run Modal */}
|
||||||
|
{showRerunModal && actionData?.data && (
|
||||||
|
<ExecuteActionModal
|
||||||
|
action={actionData.data}
|
||||||
|
onClose={() => setShowRerunModal(false)}
|
||||||
|
initialParameters={execution.config}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
@@ -295,6 +326,13 @@ export default function ExecutionDetailPage() {
|
|||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-white shadow rounded-lg p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRerunModal(true)}
|
||||||
|
disabled={!actionData?.data}
|
||||||
|
className="block w-full px-4 py-2 text-sm text-center bg-blue-50 hover:bg-blue-100 text-blue-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Re-Run with Same Parameters
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to={`/actions/${execution.action_ref}`}
|
to={`/actions/${execution.action_ref}`}
|
||||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||||
|
|||||||
Reference in New Issue
Block a user