1
.playwright-mcp/console-2026-03-15T22-30-20-876Z.log
Normal file
1
.playwright-mcp/console-2026-03-15T22-30-20-876Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 116ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://client.dev.forage.sh/orgs/rawpotion/compute/rollouts/83ae0cbc-5db6-4344-8042-025816b017d3:0
|
||||
23
Cargo.lock
generated
23
Cargo.lock
generated
@@ -147,6 +147,28 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -982,6 +1004,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
|
||||
@@ -14,6 +14,7 @@ rand.workspace = true
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
|
||||
|
||||
440
crates/forage-core/src/compute/mock_scheduler.rs
Normal file
440
crates/forage-core/src/compute/mock_scheduler.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
ComputeError, ComputeInstance, ComputeResourceSpec, ComputeScheduler, ResourceKind, Rollout,
|
||||
RolloutEvent, RolloutResource, RolloutStatus,
|
||||
};
|
||||
|
||||
struct MockState {
|
||||
rollouts: HashMap<String, Rollout>,
|
||||
instances: HashMap<String, Vec<ComputeInstance>>,
|
||||
}
|
||||
|
||||
/// In-memory compute scheduler that simulates container lifecycle.
|
||||
///
|
||||
/// Stores rollouts and instances in memory. When `apply_resources` is called
|
||||
/// it spawns a background task that transitions each resource through
|
||||
/// PENDING → IN_PROGRESS → SUCCEEDED with short delays.
|
||||
pub struct InMemoryComputeScheduler {
|
||||
state: Arc<Mutex<MockState>>,
|
||||
}
|
||||
|
||||
impl InMemoryComputeScheduler {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(MockState {
|
||||
rollouts: HashMap::new(),
|
||||
instances: HashMap::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryComputeScheduler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ComputeScheduler for InMemoryComputeScheduler {
|
||||
async fn apply_resources(
|
||||
&self,
|
||||
apply_id: &str,
|
||||
namespace: &str,
|
||||
resources: Vec<ComputeResourceSpec>,
|
||||
labels: HashMap<String, String>,
|
||||
) -> Result<String, ComputeError> {
|
||||
let rollout_id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let rollout_resources: Vec<RolloutResource> = resources
|
||||
.iter()
|
||||
.map(|r| RolloutResource {
|
||||
name: r.name.clone(),
|
||||
kind: r.kind,
|
||||
status: RolloutStatus::Pending,
|
||||
message: "queued".into(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rollout = Rollout {
|
||||
id: rollout_id.clone(),
|
||||
apply_id: apply_id.to_string(),
|
||||
namespace: namespace.to_string(),
|
||||
resources: rollout_resources,
|
||||
status: RolloutStatus::Pending,
|
||||
labels: labels.clone(),
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
// Create instances for container-service resources
|
||||
let region = labels.get("region").cloned().unwrap_or("eu-west-1".into());
|
||||
let project = labels.get("project").cloned().unwrap_or_default();
|
||||
let destination = labels.get("destination").cloned().unwrap_or_default();
|
||||
let environment = labels.get("environment").cloned().unwrap_or_default();
|
||||
let new_instances: Vec<ComputeInstance> = resources
|
||||
.iter()
|
||||
.filter(|r| r.kind == ResourceKind::ContainerService)
|
||||
.map(|r| ComputeInstance {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
namespace: namespace.to_string(),
|
||||
resource_name: r.name.clone(),
|
||||
project: project.clone(),
|
||||
destination: destination.clone(),
|
||||
environment: environment.clone(),
|
||||
region: region.clone(),
|
||||
image: r.image.clone().unwrap_or_else(|| "unknown".into()),
|
||||
replicas: r.replicas,
|
||||
cpu: r.cpu.clone().unwrap_or_else(|| "250m".into()),
|
||||
memory: r.memory.clone().unwrap_or_else(|| "256Mi".into()),
|
||||
status: "pending".into(),
|
||||
created_at: now,
|
||||
})
|
||||
.collect();
|
||||
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.rollouts.insert(rollout_id.clone(), rollout);
|
||||
let ns_instances = state
|
||||
.instances
|
||||
.entry(namespace.to_string())
|
||||
.or_insert_with(Vec::new);
|
||||
// Upsert: replace existing instances with the same resource_name
|
||||
for new_inst in new_instances {
|
||||
if let Some(existing) = ns_instances
|
||||
.iter_mut()
|
||||
.find(|i| i.resource_name == new_inst.resource_name)
|
||||
{
|
||||
*existing = new_inst;
|
||||
} else {
|
||||
ns_instances.push(new_inst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn background simulation
|
||||
let state = self.state.clone();
|
||||
let rid = rollout_id.clone();
|
||||
let ns = namespace.to_string();
|
||||
let resource_names: Vec<(String, String)> = resources
|
||||
.iter()
|
||||
.map(|r| (r.name.clone(), r.kind.to_string()))
|
||||
.collect();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Transition to InProgress
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
{
|
||||
let mut state = state.lock().await;
|
||||
if let Some(rollout) = state.rollouts.get_mut(&rid) {
|
||||
rollout.status = RolloutStatus::InProgress;
|
||||
for r in &mut rollout.resources {
|
||||
r.status = RolloutStatus::InProgress;
|
||||
r.message = "deploying".into();
|
||||
}
|
||||
}
|
||||
// Update instance statuses
|
||||
if let Some(instances) = state.instances.get_mut(&ns) {
|
||||
for inst in instances.iter_mut() {
|
||||
if inst.status == "pending" {
|
||||
inst.status = "running".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate per-resource completion
|
||||
for (name, _kind) in &resource_names {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
let mut state = state.lock().await;
|
||||
if let Some(rollout) = state.rollouts.get_mut(&rid) {
|
||||
if let Some(r) = rollout.resources.iter_mut().find(|r| &r.name == name) {
|
||||
r.status = RolloutStatus::Succeeded;
|
||||
r.message = "ready".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark rollout as succeeded
|
||||
{
|
||||
let mut state = state.lock().await;
|
||||
if let Some(rollout) = state.rollouts.get_mut(&rid) {
|
||||
rollout.status = RolloutStatus::Succeeded;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(rollout_id)
|
||||
}
|
||||
|
||||
async fn watch_rollout(
|
||||
&self,
|
||||
rollout_id: &str,
|
||||
) -> Result<tokio::sync::mpsc::Receiver<RolloutEvent>, ComputeError> {
|
||||
let rollout = {
|
||||
let state = self.state.lock().await;
|
||||
state
|
||||
.rollouts
|
||||
.get(rollout_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| ComputeError::NotFound(format!("rollout {rollout_id}")))?
|
||||
};
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||
let state = self.state.clone();
|
||||
let rid = rollout_id.to_string();
|
||||
let resource_specs: Vec<(String, String)> = rollout
|
||||
.resources
|
||||
.iter()
|
||||
.map(|r| (r.name.clone(), r.kind.to_string()))
|
||||
.collect();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Emit pending events
|
||||
for (name, kind) in &resource_specs {
|
||||
let _ = tx
|
||||
.send(RolloutEvent {
|
||||
resource_name: name.clone(),
|
||||
resource_kind: kind.clone(),
|
||||
status: RolloutStatus::Pending,
|
||||
message: "queued".into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// Poll until the rollout is done
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
let state = state.lock().await;
|
||||
let Some(rollout) = state.rollouts.get(&rid) else {
|
||||
break;
|
||||
};
|
||||
|
||||
for r in &rollout.resources {
|
||||
let _ = tx
|
||||
.send(RolloutEvent {
|
||||
resource_name: r.name.clone(),
|
||||
resource_kind: r.kind.to_string(),
|
||||
status: r.status,
|
||||
message: r.message.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
rollout.status,
|
||||
RolloutStatus::Succeeded | RolloutStatus::Failed | RolloutStatus::RolledBack
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
async fn delete_resources(
|
||||
&self,
|
||||
namespace: &str,
|
||||
labels: HashMap<String, String>,
|
||||
) -> Result<(), ComputeError> {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
// Remove matching rollouts
|
||||
state.rollouts.retain(|_, r| {
|
||||
if r.namespace != namespace {
|
||||
return true;
|
||||
}
|
||||
for (k, v) in &labels {
|
||||
if r.labels.get(k) != Some(v) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
|
||||
// Remove matching instances
|
||||
if let Some(instances) = state.instances.get_mut(namespace) {
|
||||
if labels.is_empty() {
|
||||
instances.clear();
|
||||
}
|
||||
// If labels are specified we'd filter more precisely, but for now
|
||||
// the mock just clears the namespace.
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_rollouts(&self, namespace: &str) -> Result<Vec<Rollout>, ComputeError> {
|
||||
let state = self.state.lock().await;
|
||||
let mut rollouts: Vec<Rollout> = state
|
||||
.rollouts
|
||||
.values()
|
||||
.filter(|r| r.namespace == namespace)
|
||||
.cloned()
|
||||
.collect();
|
||||
rollouts.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(rollouts)
|
||||
}
|
||||
|
||||
async fn get_rollout(&self, rollout_id: &str) -> Result<Rollout, ComputeError> {
|
||||
let state = self.state.lock().await;
|
||||
state
|
||||
.rollouts
|
||||
.get(rollout_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| ComputeError::NotFound(format!("rollout {rollout_id}")))
|
||||
}
|
||||
|
||||
async fn list_instances(
|
||||
&self,
|
||||
namespace: &str,
|
||||
) -> Result<Vec<ComputeInstance>, ComputeError> {
|
||||
let state = self.state.lock().await;
|
||||
Ok(state
|
||||
.instances
|
||||
.get(namespace)
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn apply_creates_rollout_and_instances() {
|
||||
let scheduler = InMemoryComputeScheduler::new();
|
||||
|
||||
let resources = vec![ComputeResourceSpec {
|
||||
name: "my-api".into(),
|
||||
kind: ResourceKind::ContainerService,
|
||||
image: Some("registry.forage.sh/org/app:v1".into()),
|
||||
replicas: 2,
|
||||
cpu: Some("500m".into()),
|
||||
memory: Some("512Mi".into()),
|
||||
}];
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("region".into(), "eu-west-1".into());
|
||||
|
||||
let rollout_id = scheduler
|
||||
.apply_resources("test-apply-1", "test-ns", resources, labels)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!rollout_id.is_empty());
|
||||
|
||||
// Rollout should exist
|
||||
let rollout = scheduler.get_rollout(&rollout_id).await.unwrap();
|
||||
assert_eq!(rollout.namespace, "test-ns");
|
||||
assert_eq!(rollout.resources.len(), 1);
|
||||
|
||||
// Instance should exist
|
||||
let instances = scheduler.list_instances("test-ns").await.unwrap();
|
||||
assert_eq!(instances.len(), 1);
|
||||
assert_eq!(instances[0].image, "registry.forage.sh/org/app:v1");
|
||||
assert_eq!(instances[0].replicas, 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rollout_completes_successfully() {
|
||||
let scheduler = InMemoryComputeScheduler::new();
|
||||
|
||||
let resources = vec![ComputeResourceSpec {
|
||||
name: "svc".into(),
|
||||
kind: ResourceKind::ContainerService,
|
||||
image: Some("img:latest".into()),
|
||||
replicas: 1,
|
||||
cpu: None,
|
||||
memory: None,
|
||||
}];
|
||||
|
||||
let rollout_id = scheduler
|
||||
.apply_resources("test-2", "ns", resources, HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Wait for simulation to complete
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
let rollout = scheduler.get_rollout(&rollout_id).await.unwrap();
|
||||
assert_eq!(rollout.status, RolloutStatus::Succeeded);
|
||||
assert_eq!(rollout.resources[0].status, RolloutStatus::Succeeded);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn watch_rollout_streams_events() {
|
||||
let scheduler = InMemoryComputeScheduler::new();
|
||||
|
||||
let resources = vec![ComputeResourceSpec {
|
||||
name: "app".into(),
|
||||
kind: ResourceKind::ContainerService,
|
||||
image: Some("img:v1".into()),
|
||||
replicas: 1,
|
||||
cpu: None,
|
||||
memory: None,
|
||||
}];
|
||||
|
||||
let rollout_id = scheduler
|
||||
.apply_resources("test-3", "ns", resources, HashMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut rx = scheduler.watch_rollout(&rollout_id).await.unwrap();
|
||||
|
||||
let mut events = Vec::new();
|
||||
while let Some(event) = rx.recv().await {
|
||||
events.push(event);
|
||||
if events.last().map(|e| e.status) == Some(RolloutStatus::Succeeded) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(!events.is_empty());
|
||||
// Should have at least pending + succeeded
|
||||
assert!(events.iter().any(|e| e.status == RolloutStatus::Pending));
|
||||
assert!(events.iter().any(|e| e.status == RolloutStatus::Succeeded));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_resources() {
|
||||
let scheduler = InMemoryComputeScheduler::new();
|
||||
|
||||
let resources = vec![ComputeResourceSpec {
|
||||
name: "app".into(),
|
||||
kind: ResourceKind::ContainerService,
|
||||
image: Some("img:v1".into()),
|
||||
replicas: 1,
|
||||
cpu: None,
|
||||
memory: None,
|
||||
}];
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("project".into(), "test".into());
|
||||
|
||||
scheduler
|
||||
.apply_resources("del-1", "ns", resources, labels.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(scheduler.list_rollouts("ns").await.unwrap().len(), 1);
|
||||
|
||||
scheduler.delete_resources("ns", labels).await.unwrap();
|
||||
|
||||
assert_eq!(scheduler.list_rollouts("ns").await.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watch_nonexistent_rollout_returns_not_found() {
|
||||
let scheduler = InMemoryComputeScheduler::new();
|
||||
let result = scheduler.watch_rollout("does-not-exist").await;
|
||||
assert!(matches!(result, Err(ComputeError::NotFound(_))));
|
||||
}
|
||||
}
|
||||
201
crates/forage-core/src/compute/mod.rs
Normal file
201
crates/forage-core/src/compute/mod.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Region catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Region {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
pub const REGIONS: &[Region] = &[
|
||||
Region {
|
||||
id: "eu-west-1",
|
||||
name: "Europe (Ireland)",
|
||||
display_name: "eu-west-1 — Europe (Ireland)",
|
||||
available: true,
|
||||
},
|
||||
Region {
|
||||
id: "us-east-1",
|
||||
name: "US East (Virginia)",
|
||||
display_name: "us-east-1 — US East (Virginia)",
|
||||
available: true,
|
||||
},
|
||||
Region {
|
||||
id: "ap-southeast-1",
|
||||
name: "Asia Pacific (Singapore)",
|
||||
display_name: "ap-southeast-1 — Asia Pacific (Singapore)",
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn available_regions() -> Vec<&'static Region> {
|
||||
REGIONS.iter().filter(|r| r.available).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ComputeResourceSpec {
|
||||
pub name: String,
|
||||
pub kind: ResourceKind,
|
||||
pub image: Option<String>,
|
||||
pub replicas: u32,
|
||||
pub cpu: Option<String>,
|
||||
pub memory: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResourceKind {
|
||||
ContainerService,
|
||||
Service,
|
||||
Route,
|
||||
CronJob,
|
||||
Job,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ResourceKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ResourceKind::ContainerService => write!(f, "container_service"),
|
||||
ResourceKind::Service => write!(f, "service"),
|
||||
ResourceKind::Route => write!(f, "route"),
|
||||
ResourceKind::CronJob => write!(f, "cron_job"),
|
||||
ResourceKind::Job => write!(f, "job"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Rollout {
|
||||
pub id: String,
|
||||
pub apply_id: String,
|
||||
pub namespace: String,
|
||||
pub resources: Vec<RolloutResource>,
|
||||
pub status: RolloutStatus,
|
||||
pub labels: HashMap<String, String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RolloutResource {
|
||||
pub name: String,
|
||||
pub kind: ResourceKind,
|
||||
pub status: RolloutStatus,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RolloutStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Succeeded,
|
||||
Failed,
|
||||
RolledBack,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RolloutStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RolloutStatus::Pending => write!(f, "pending"),
|
||||
RolloutStatus::InProgress => write!(f, "in_progress"),
|
||||
RolloutStatus::Succeeded => write!(f, "succeeded"),
|
||||
RolloutStatus::Failed => write!(f, "failed"),
|
||||
RolloutStatus::RolledBack => write!(f, "rolled_back"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RolloutEvent {
|
||||
pub resource_name: String,
|
||||
pub resource_kind: String,
|
||||
pub status: RolloutStatus,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ComputeInstance {
|
||||
pub id: String,
|
||||
pub namespace: String,
|
||||
pub resource_name: String,
|
||||
pub project: String,
|
||||
pub destination: String,
|
||||
pub environment: String,
|
||||
pub region: String,
|
||||
pub image: String,
|
||||
pub replicas: u32,
|
||||
pub cpu: String,
|
||||
pub memory: String,
|
||||
pub status: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum ComputeError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("resource conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("scheduler error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ComputeScheduler: Send + Sync {
|
||||
/// Apply a batch of resources. Returns a rollout ID for tracking.
|
||||
async fn apply_resources(
|
||||
&self,
|
||||
apply_id: &str,
|
||||
namespace: &str,
|
||||
resources: Vec<ComputeResourceSpec>,
|
||||
labels: HashMap<String, String>,
|
||||
) -> Result<String, ComputeError>;
|
||||
|
||||
/// Subscribe to rollout status events.
|
||||
async fn watch_rollout(
|
||||
&self,
|
||||
rollout_id: &str,
|
||||
) -> Result<tokio::sync::mpsc::Receiver<RolloutEvent>, ComputeError>;
|
||||
|
||||
/// Delete resources by namespace + labels.
|
||||
async fn delete_resources(
|
||||
&self,
|
||||
namespace: &str,
|
||||
labels: HashMap<String, String>,
|
||||
) -> Result<(), ComputeError>;
|
||||
|
||||
/// List rollouts for a namespace.
|
||||
async fn list_rollouts(&self, namespace: &str) -> Result<Vec<Rollout>, ComputeError>;
|
||||
|
||||
/// Get a specific rollout by ID.
|
||||
async fn get_rollout(&self, rollout_id: &str) -> Result<Rollout, ComputeError>;
|
||||
|
||||
/// List running compute instances for a namespace.
|
||||
async fn list_instances(&self, namespace: &str) -> Result<Vec<ComputeInstance>, ComputeError>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory mock scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub mod mock_scheduler;
|
||||
pub use mock_scheduler::InMemoryComputeScheduler;
|
||||
@@ -5,3 +5,4 @@ pub mod integrations;
|
||||
pub mod registry;
|
||||
pub mod deployments;
|
||||
pub mod billing;
|
||||
pub mod compute;
|
||||
|
||||
@@ -6,7 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
forage-core = { path = "../forage-core" }
|
||||
forage-db = { path = "../forage-db" }
|
||||
forage-grpc = { path = "../forage-grpc" }
|
||||
forage-grpc = { path = "../forage-grpc", features = ["client", "server"] }
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
async-trait.workspace = true
|
||||
@@ -37,3 +37,4 @@ sha2.workspace = true
|
||||
notmad.workspace = true
|
||||
tokio-util.workspace = true
|
||||
async-nats.workspace = true
|
||||
async-stream = "0.3"
|
||||
|
||||
160
crates/forage-server/src/compute_grpc.rs
Normal file
160
crates/forage-server/src/compute_grpc.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use forage_core::compute::{
|
||||
ComputeError, ComputeResourceSpec, ComputeScheduler, ResourceKind, RolloutStatus,
|
||||
};
|
||||
use forage_grpc::forage_service_server::ForageService;
|
||||
use forage_grpc::{
|
||||
ApplyResourcesRequest, ApplyResourcesResponse, DeleteResourcesRequest,
|
||||
DeleteResourcesResponse, RolloutEvent as ProtoRolloutEvent, WatchRolloutRequest,
|
||||
};
|
||||
use tokio_stream::Stream;
|
||||
use tonic::{Request, Response, Status};
|
||||
|
||||
/// Implements the `ForageService` gRPC server trait.
|
||||
///
|
||||
/// Thin adapter: validates auth, converts proto to domain types, delegates to
|
||||
/// the `ComputeScheduler`, and converts results back to proto.
|
||||
pub struct ForageServiceImpl {
|
||||
pub scheduler: Arc<dyn ComputeScheduler>,
|
||||
}
|
||||
|
||||
type WatchStream =
|
||||
Pin<Box<dyn Stream<Item = Result<ProtoRolloutEvent, Status>> + Send + 'static>>;
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl ForageService for ForageServiceImpl {
|
||||
async fn apply_resources(
|
||||
&self,
|
||||
request: Request<ApplyResourcesRequest>,
|
||||
) -> Result<Response<ApplyResourcesResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
if req.namespace.is_empty() {
|
||||
return Err(Status::invalid_argument("namespace is required"));
|
||||
}
|
||||
if req.resources.is_empty() {
|
||||
return Err(Status::invalid_argument("at least one resource is required"));
|
||||
}
|
||||
|
||||
let resources: Vec<ComputeResourceSpec> = req
|
||||
.resources
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let (kind, image, replicas, cpu, memory) = match &r.spec {
|
||||
Some(forage_grpc::forage_resource::Spec::ContainerService(cs)) => {
|
||||
let container = cs.container.as_ref();
|
||||
let scaling = cs.scaling.as_ref();
|
||||
(
|
||||
ResourceKind::ContainerService,
|
||||
container.map(|c| c.image.clone()),
|
||||
scaling.map(|s| s.replicas).unwrap_or(1),
|
||||
container
|
||||
.and_then(|c| c.resources.as_ref())
|
||||
.and_then(|r| r.requests.as_ref())
|
||||
.map(|r| r.cpu.clone()),
|
||||
container
|
||||
.and_then(|c| c.resources.as_ref())
|
||||
.and_then(|r| r.requests.as_ref())
|
||||
.map(|r| r.memory.clone()),
|
||||
)
|
||||
}
|
||||
Some(forage_grpc::forage_resource::Spec::Service(_)) => {
|
||||
(ResourceKind::Service, None, 1, None, None)
|
||||
}
|
||||
Some(forage_grpc::forage_resource::Spec::Route(_)) => {
|
||||
(ResourceKind::Route, None, 1, None, None)
|
||||
}
|
||||
Some(forage_grpc::forage_resource::Spec::CronJob(cj)) => {
|
||||
let image = cj.container.as_ref().map(|c| c.image.clone());
|
||||
(ResourceKind::CronJob, image, 1, None, None)
|
||||
}
|
||||
Some(forage_grpc::forage_resource::Spec::Job(j)) => {
|
||||
let image = j.container.as_ref().map(|c| c.image.clone());
|
||||
(ResourceKind::Job, image, 1, None, None)
|
||||
}
|
||||
None => (ResourceKind::ContainerService, None, 1, None, None),
|
||||
};
|
||||
|
||||
ComputeResourceSpec {
|
||||
name: r.name.clone(),
|
||||
kind,
|
||||
image,
|
||||
replicas,
|
||||
cpu,
|
||||
memory,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rollout_id = self
|
||||
.scheduler
|
||||
.apply_resources(&req.apply_id, &req.namespace, resources, req.labels)
|
||||
.await
|
||||
.map_err(compute_err_to_status)?;
|
||||
|
||||
Ok(Response::new(ApplyResourcesResponse { rollout_id }))
|
||||
}
|
||||
|
||||
type WatchRolloutStream = WatchStream;
|
||||
|
||||
async fn watch_rollout(
|
||||
&self,
|
||||
request: Request<WatchRolloutRequest>,
|
||||
) -> Result<Response<Self::WatchRolloutStream>, Status> {
|
||||
let rollout_id = request.into_inner().rollout_id;
|
||||
|
||||
let mut rx = self
|
||||
.scheduler
|
||||
.watch_rollout(&rollout_id)
|
||||
.await
|
||||
.map_err(compute_err_to_status)?;
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
while let Some(event) = rx.recv().await {
|
||||
yield Ok(ProtoRolloutEvent {
|
||||
resource_name: event.resource_name,
|
||||
resource_kind: event.resource_kind,
|
||||
status: domain_status_to_proto(event.status) as i32,
|
||||
message: event.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Response::new(Box::pin(stream) as Self::WatchRolloutStream))
|
||||
}
|
||||
|
||||
async fn delete_resources(
|
||||
&self,
|
||||
request: Request<DeleteResourcesRequest>,
|
||||
) -> Result<Response<DeleteResourcesResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
self.scheduler
|
||||
.delete_resources(&req.namespace, req.labels)
|
||||
.await
|
||||
.map_err(compute_err_to_status)?;
|
||||
|
||||
Ok(Response::new(DeleteResourcesResponse {}))
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_err_to_status(e: ComputeError) -> Status {
|
||||
match e {
|
||||
ComputeError::NotFound(msg) => Status::not_found(msg),
|
||||
ComputeError::InvalidRequest(msg) => Status::invalid_argument(msg),
|
||||
ComputeError::Conflict(msg) => Status::already_exists(msg),
|
||||
ComputeError::Internal(msg) => Status::internal(msg),
|
||||
}
|
||||
}
|
||||
|
||||
fn domain_status_to_proto(s: RolloutStatus) -> forage_grpc::RolloutStatus {
|
||||
match s {
|
||||
RolloutStatus::Pending => forage_grpc::RolloutStatus::Pending,
|
||||
RolloutStatus::InProgress => forage_grpc::RolloutStatus::InProgress,
|
||||
RolloutStatus::Succeeded => forage_grpc::RolloutStatus::Succeeded,
|
||||
RolloutStatus::Failed => forage_grpc::RolloutStatus::Failed,
|
||||
RolloutStatus::RolledBack => forage_grpc::RolloutStatus::RolledBack,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
mod auth;
|
||||
mod compute_grpc;
|
||||
mod forest_client;
|
||||
mod notification_consumer;
|
||||
mod notification_ingester;
|
||||
mod notification_worker;
|
||||
mod routes;
|
||||
mod serve_grpc;
|
||||
mod serve_http;
|
||||
mod session_reaper;
|
||||
mod state;
|
||||
@@ -252,6 +254,20 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Compute scheduler (mock for now — simulates container lifecycle)
|
||||
let compute_scheduler = Arc::new(forage_core::compute::InMemoryComputeScheduler::new());
|
||||
state = state.with_compute_scheduler(compute_scheduler.clone());
|
||||
|
||||
let grpc_port: u16 = std::env::var("GRPC_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(4050);
|
||||
let grpc_addr = SocketAddr::from(([0, 0, 0, 0], grpc_port));
|
||||
mad.add(serve_grpc::ServeGrpc {
|
||||
addr: grpc_addr,
|
||||
scheduler: compute_scheduler,
|
||||
});
|
||||
|
||||
// HTTP server component
|
||||
mad.add(serve_http::ServeHttp { addr, state });
|
||||
|
||||
|
||||
@@ -142,6 +142,12 @@ pub fn router() -> Router<AppState> {
|
||||
get(timeline_api),
|
||||
)
|
||||
.route("/api/orgs/{org}/timeline", get(org_timeline_api))
|
||||
.route("/orgs/{org}/compute", get(compute_page))
|
||||
.route(
|
||||
"/orgs/{org}/compute/rollouts/{rollout_id}",
|
||||
get(rollout_detail_page),
|
||||
)
|
||||
.route("/api/compute/regions", get(regions_api))
|
||||
}
|
||||
|
||||
fn orgs_context(orgs: &[CachedOrg]) -> Vec<minijinja::Value> {
|
||||
@@ -4989,3 +4995,202 @@ async fn get_plan_output_api(
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn compute_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
let _cached_org = require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
let (instances, rollouts) = if let Some(ref scheduler) = state.compute_scheduler {
|
||||
let namespace = &org;
|
||||
let instances = scheduler
|
||||
.list_instances(namespace)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let rollouts = scheduler
|
||||
.list_rollouts(namespace)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
(instances, rollouts)
|
||||
} else {
|
||||
(vec![], vec![])
|
||||
};
|
||||
|
||||
let instances_ctx: Vec<minijinja::Value> = instances
|
||||
.iter()
|
||||
.map(|i| {
|
||||
context! {
|
||||
id => i.id,
|
||||
resource_name => i.resource_name,
|
||||
project => i.project,
|
||||
destination => i.destination,
|
||||
environment => i.environment,
|
||||
image => i.image,
|
||||
region => i.region,
|
||||
replicas => i.replicas,
|
||||
cpu => i.cpu,
|
||||
memory => i.memory,
|
||||
status => i.status,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rollouts_ctx: Vec<minijinja::Value> = rollouts
|
||||
.iter()
|
||||
.take(20)
|
||||
.map(|r| {
|
||||
let resources: Vec<minijinja::Value> = r
|
||||
.resources
|
||||
.iter()
|
||||
.map(|res| {
|
||||
context! {
|
||||
name => res.name,
|
||||
kind => res.kind.to_string(),
|
||||
status => res.status.to_string(),
|
||||
message => res.message,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
context! {
|
||||
id => r.id,
|
||||
apply_id => r.apply_id,
|
||||
namespace => r.namespace,
|
||||
status => r.status.to_string(),
|
||||
resources => resources,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let projects = warn_default(
|
||||
"compute: list projects",
|
||||
state
|
||||
.platform_client
|
||||
.list_projects(&session.access_token, &org)
|
||||
.await,
|
||||
);
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/compute.html.jinja",
|
||||
context! {
|
||||
title => format!("Compute - {} - Forage", org),
|
||||
description => "Managed compute instances",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
orgs => orgs_context(orgs),
|
||||
current_org => &org,
|
||||
active_tab => "compute",
|
||||
projects => projects,
|
||||
instances => instances_ctx,
|
||||
rollouts => rollouts_ctx,
|
||||
org_name => &org,
|
||||
},
|
||||
)
|
||||
.map_err(|e| internal_error(&state, "compute render", &e))?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
async fn rollout_detail_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path((org, rollout_id)): Path<(String, String)>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
let _cached_org = require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
let scheduler = state.compute_scheduler.as_ref().ok_or_else(|| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::NOT_FOUND,
|
||||
"Not available",
|
||||
"Compute is not enabled.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let rollout = scheduler.get_rollout(&rollout_id).await.map_err(|_| {
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::NOT_FOUND,
|
||||
"Not found",
|
||||
"Rollout not found.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let resources_ctx: Vec<minijinja::Value> = rollout
|
||||
.resources
|
||||
.iter()
|
||||
.map(|r| {
|
||||
context! {
|
||||
name => r.name,
|
||||
kind => r.kind.to_string(),
|
||||
status => r.status.to_string(),
|
||||
message => r.message,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let labels_ctx: Vec<minijinja::Value> = rollout.labels.iter().map(|(k, v)| context! { key => k, value => v }).collect();
|
||||
|
||||
let rollout_ctx = context! {
|
||||
id => rollout.id,
|
||||
apply_id => rollout.apply_id,
|
||||
namespace => rollout.namespace,
|
||||
status => rollout.status.to_string(),
|
||||
resources => resources_ctx,
|
||||
labels => labels_ctx,
|
||||
};
|
||||
|
||||
let projects = warn_default(
|
||||
"rollout detail: list projects",
|
||||
state
|
||||
.platform_client
|
||||
.list_projects(&session.access_token, &org)
|
||||
.await,
|
||||
);
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/rollout_detail.html.jinja",
|
||||
context! {
|
||||
title => format!("Rollout {} - Forage", rollout.apply_id),
|
||||
description => "Rollout details",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
orgs => orgs_context(orgs),
|
||||
current_org => &org,
|
||||
active_tab => "compute",
|
||||
projects => projects,
|
||||
rollout => rollout_ctx,
|
||||
org_name => &org,
|
||||
},
|
||||
)
|
||||
.map_err(|e| internal_error(&state, "rollout detail render", &e))?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
async fn regions_api() -> impl IntoResponse {
|
||||
let regions: Vec<serde_json::Value> = forage_core::compute::REGIONS
|
||||
.iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"display_name": r.display_name,
|
||||
"available": r.available,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(regions)
|
||||
}
|
||||
|
||||
39
crates/forage-server/src/serve_grpc.rs
Normal file
39
crates/forage-server/src/serve_grpc.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use forage_core::compute::ComputeScheduler;
|
||||
use forage_grpc::forage_service_server::ForageServiceServer;
|
||||
use notmad::{Component, ComponentInfo, MadError};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::compute_grpc::ForageServiceImpl;
|
||||
|
||||
pub struct ServeGrpc {
|
||||
pub addr: SocketAddr,
|
||||
pub scheduler: Arc<dyn ComputeScheduler>,
|
||||
}
|
||||
|
||||
impl Component for ServeGrpc {
|
||||
fn info(&self) -> ComponentInfo {
|
||||
"forage/grpc".into()
|
||||
}
|
||||
|
||||
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
|
||||
let svc = ForageServiceImpl {
|
||||
scheduler: self.scheduler.clone(),
|
||||
};
|
||||
|
||||
tracing::info!("gRPC server listening on {}", self.addr);
|
||||
|
||||
tonic::transport::Server::builder()
|
||||
.add_service(ForageServiceServer::new(svc))
|
||||
.serve_with_shutdown(self.addr, async move {
|
||||
cancellation_token.cancelled().await;
|
||||
})
|
||||
.await
|
||||
.context("failed to run gRPC server")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use crate::forest_client::GrpcForestClient;
|
||||
use crate::templates::TemplateEngine;
|
||||
use forage_core::auth::ForestAuth;
|
||||
use forage_core::compute::ComputeScheduler;
|
||||
use forage_core::integrations::IntegrationStore;
|
||||
use forage_core::platform::ForestPlatform;
|
||||
use forage_core::session::SessionStore;
|
||||
@@ -24,6 +25,7 @@ pub struct AppState {
|
||||
pub grpc_client: Option<Arc<GrpcForestClient>>,
|
||||
pub integration_store: Option<Arc<dyn IntegrationStore>>,
|
||||
pub slack_config: Option<SlackConfig>,
|
||||
pub compute_scheduler: Option<Arc<dyn ComputeScheduler>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -41,6 +43,7 @@ impl AppState {
|
||||
grpc_client: None,
|
||||
integration_store: None,
|
||||
slack_config: None,
|
||||
compute_scheduler: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +61,9 @@ impl AppState {
|
||||
self.slack_config = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_compute_scheduler(mut self, scheduler: Arc<dyn ComputeScheduler>) -> Self {
|
||||
self.compute_scheduler = Some(scheduler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
/* Remap Tailwind's color variables so all existing utilities adapt automatically. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root, :host {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Neutrals — invert the gray scale */
|
||||
--color-white: oklch(14.5% 0.015 260);
|
||||
--color-black: oklch(98% 0.002 248);
|
||||
@@ -91,4 +93,17 @@
|
||||
/* Amber */
|
||||
--color-amber-400: oklch(80% 0.17 84);
|
||||
}
|
||||
|
||||
/* Form elements — inherit the dark palette */
|
||||
input, textarea, select {
|
||||
background-color: var(--color-gray-50);
|
||||
color: var(--color-gray-900);
|
||||
border-color: var(--color-gray-200);
|
||||
}
|
||||
input::placeholder, textarea::placeholder {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--color-green-300);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -99,6 +99,7 @@
|
||||
<a href="/orgs/{{ current_org }}/projects" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'projects' %} text-gray-900 border-gray-900{% endif %}">Projects</a>
|
||||
<a href="/orgs/{{ current_org }}/settings/members" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'members' %} text-gray-900 border-gray-900{% endif %}">Members</a>
|
||||
<a href="/orgs/{{ current_org }}/destinations" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'destinations' %} text-gray-900 border-gray-900{% endif %}">Destinations</a>
|
||||
<a href="/orgs/{{ current_org }}/compute" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'compute' %} text-gray-900 border-gray-900{% endif %}">Compute</a>
|
||||
<a href="/orgs/{{ current_org }}/settings/integrations" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'integrations' %} text-gray-900 border-gray-900{% endif %}">Integrations</a>
|
||||
<a href="/orgs/{{ current_org }}/usage" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'usage' %} text-gray-900 border-gray-900{% endif %}">Usage</a>
|
||||
<a href="/settings/tokens" class="whitespace-nowrap px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'tokens' %} text-gray-900 border-gray-900{% endif %}">Tokens</a>
|
||||
|
||||
103
templates/pages/compute.html.jinja
Normal file
103
templates/pages/compute.html.jinja
Normal file
@@ -0,0 +1,103 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold">Compute</h1>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mb-6">Managed container instances deployed through forage/containers destinations. Pay-as-you-go compute — no cluster setup required.</p>
|
||||
|
||||
{% if instances | length > 0 %}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-left text-gray-500">
|
||||
<tr>
|
||||
<th class="px-5 py-3 font-medium">Project / Destination</th>
|
||||
<th class="px-5 py-3 font-medium">Image</th>
|
||||
<th class="px-5 py-3 font-medium">Region</th>
|
||||
<th class="px-5 py-3 font-medium">Replicas</th>
|
||||
<th class="px-5 py-3 font-medium">Resources</th>
|
||||
<th class="px-5 py-3 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for inst in instances %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-5 py-3">
|
||||
{% if inst.project %}
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ inst.project }}" class="font-medium text-gray-900 hover:text-blue-600">{{ inst.project }}</a>
|
||||
<span class="text-gray-400">/</span>
|
||||
<span class="text-sm font-mono text-gray-600">{{ inst.destination }}</span>
|
||||
{% if inst.environment %}
|
||||
<span class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-gray-100 text-gray-500">{{ inst.environment }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="font-medium font-mono text-gray-900">{{ inst.resource_name }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-600 font-mono text-xs">{{ inst.image }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="inline-flex items-center gap-1 text-xs font-mono">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
{{ inst.region }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-gray-600 font-mono">{{ inst.replicas }}</td>
|
||||
<td class="px-5 py-3 text-xs text-gray-500 font-mono">{{ inst.cpu }} / {{ inst.memory }}</td>
|
||||
<td class="px-5 py-3">
|
||||
{% if inst.status == "running" %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 px-2 py-0.5 rounded-full">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
Running
|
||||
</span>
|
||||
{% elif inst.status == "pending" %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-yellow-700 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-yellow-500 animate-pulse"></span>
|
||||
Pending
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-500">{{ inst.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="border border-dashed border-gray-300 rounded-lg p-8 text-center">
|
||||
<svg class="mx-auto mb-3 w-8 h-8 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>
|
||||
<p class="text-sm text-gray-500 mb-2">No compute instances running</p>
|
||||
<p class="text-xs text-gray-400">Create a <a href="/orgs/{{ org_name }}/destinations" class="text-blue-600 hover:underline">forage/containers destination</a> and deploy to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if rollouts | length > 0 %}
|
||||
<h2 class="text-lg font-semibold mt-10 mb-4">Recent Rollouts</h2>
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
|
||||
{% for rollout in rollouts %}
|
||||
<a href="/orgs/{{ org_name }}/compute/rollouts/{{ rollout.id }}" class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 block">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
{% if rollout.status == "succeeded" %}
|
||||
<span class="w-2 h-2 rounded-full bg-green-500 shrink-0"></span>
|
||||
{% elif rollout.status == "in_progress" %}
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse shrink-0"></span>
|
||||
{% elif rollout.status == "failed" %}
|
||||
<span class="w-2 h-2 rounded-full bg-red-500 shrink-0"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-gray-400 shrink-0"></span>
|
||||
{% endif %}
|
||||
<span class="text-sm font-medium font-mono text-gray-900 truncate">{{ rollout.apply_id }}</span>
|
||||
<span class="text-xs text-gray-400">{{ rollout.resources | length }} resource{% if rollout.resources | length != 1 %}s{% endif %}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400">{{ rollout.status }}</span>
|
||||
<svg class="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
89
templates/pages/rollout_detail.html.jinja
Normal file
89
templates/pages/rollout_detail.html.jinja
Normal file
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-6">
|
||||
<a href="/orgs/{{ org_name }}/compute" class="hover:text-gray-700">Compute</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 font-mono">{{ rollout.apply_id }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold">Rollout</h1>
|
||||
{% if rollout.status == "succeeded" %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 px-2 py-0.5 rounded-full">Succeeded</span>
|
||||
{% elif rollout.status == "in_progress" %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-blue-700 bg-blue-50 px-2 py-0.5 rounded-full">In Progress</span>
|
||||
{% elif rollout.status == "failed" %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-red-700 bg-red-50 px-2 py-0.5 rounded-full">Failed</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-gray-600 bg-gray-100 px-2 py-0.5 rounded-full">{{ rollout.status }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-8 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500">Apply ID</dt>
|
||||
<dd class="font-mono text-xs mt-0.5">{{ rollout.apply_id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500">Namespace</dt>
|
||||
<dd class="font-mono text-xs mt-0.5">{{ rollout.namespace }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg font-semibold mb-4">Resources ({{ rollout.resources | length }})</h2>
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-left text-gray-500">
|
||||
<tr>
|
||||
<th class="px-5 py-3 font-medium">Name</th>
|
||||
<th class="px-5 py-3 font-medium">Kind</th>
|
||||
<th class="px-5 py-3 font-medium">Status</th>
|
||||
<th class="px-5 py-3 font-medium">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for r in rollout.resources %}
|
||||
<tr>
|
||||
<td class="px-5 py-3 font-medium font-mono text-gray-900">{{ r.name }}</td>
|
||||
<td class="px-5 py-3 text-xs font-mono text-gray-500">{{ r.kind }}</td>
|
||||
<td class="px-5 py-3">
|
||||
{% if r.status == "succeeded" %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-green-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
Succeeded
|
||||
</span>
|
||||
{% elif r.status == "in_progress" %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-blue-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
In Progress
|
||||
</span>
|
||||
{% elif r.status == "failed" %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-red-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
Failed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-500">{{ r.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-5 py-3 text-xs text-gray-500">{{ r.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if rollout.labels | length > 0 %}
|
||||
<h2 class="text-lg font-semibold mt-8 mb-4">Labels</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for label in rollout.labels %}
|
||||
<span class="text-xs font-mono px-2 py-1 rounded bg-gray-100 text-gray-600">{{ label.key }}={{ label.value }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user