feat: add many things

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-08 23:00:03 +01:00
parent 45353089c2
commit 5a5f9a3003
104 changed files with 23417 additions and 2027 deletions

View File

@@ -0,0 +1 @@
[ 72ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0

View File

@@ -0,0 +1 @@
[ 9ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/users/kjuulh:0

View File

@@ -0,0 +1 @@
[ 7745ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/users/kjuulh:0

View File

@@ -0,0 +1 @@
[ 243429ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/users/kjuulh:0

View File

@@ -0,0 +1 @@
[ 11ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/testorg/projects/my-api/policies:0

View File

@@ -0,0 +1 @@
[ 83695ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/projects/other-example/pipelines:0

View File

@@ -0,0 +1 @@
[ 27797ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/projects/other-example/pipelines:0

View File

@@ -0,0 +1,10 @@
[ 183938ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
[ 185942ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
[ 189946ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
[ 197960ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
[ 213961ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
[ 243962ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
[ 273963ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
[ 303968ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
[ 333973ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0
[ 363977ms] [ERROR] Failed to load resource: the server responded with a status of 502 (Bad Gateway) @ http://localhost:3000/orgs/rawpotion/projects/other-example/events:0

View File

@@ -0,0 +1 @@
[ 69ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0

View File

@@ -0,0 +1,4 @@
[ 42748ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 43749ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 49108ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 50109ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0

View File

@@ -0,0 +1 @@
[ 281704ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/projects/service-example:0

View File

@@ -0,0 +1,10 @@
[ 136576ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 137577ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 139578ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 152714ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 153715ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 601126ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 602127ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 604128ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 608129ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0
[ 616130ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/orgs/rawpotion/projects/service-example/events:0

View File

@@ -0,0 +1,5 @@
[ 80067ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
[ 90065ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
[ 100065ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
[ 110065ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
[ 120065ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0

View File

@@ -0,0 +1 @@
[ 1030036ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

187
Cargo.lock generated
View File

@@ -195,6 +195,7 @@ dependencies = [
"axum-core",
"bytes",
"cookie",
"form_urlencoded",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
@@ -203,6 +204,8 @@ dependencies = [
"pin-project-lite",
"rustversion",
"serde_core",
"serde_html_form",
"serde_path_to_error",
"tower-layer",
"tower-service",
"tracing",
@@ -510,7 +513,7 @@ dependencies = [
"hex",
"hex-literal",
"platform-info",
"reqwest",
"reqwest 0.11.27",
"serde",
"serde_graphql_input",
"serde_json",
@@ -832,17 +835,24 @@ dependencies = [
"forage-core",
"forage-db",
"forage-grpc",
"futures-util",
"minijinja",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
"serde",
"serde_json",
"sqlx",
"time",
"tokio",
"tokio-stream",
"tonic",
"tower",
"tower-http",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"urlencoding",
"uuid",
]
@@ -1026,7 +1036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cdf7b487d864c2939b23902291a5041bc4a84418268f25fda1c8d4e15ad8fa"
dependencies = [
"graphql_query_derive",
"reqwest",
"reqwest 0.11.27",
"serde",
"serde_json",
]
@@ -1327,13 +1337,16 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"hyper 1.8.1",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"tokio",
@@ -1503,6 +1516,16 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -1768,6 +1791,82 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opentelemetry"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "opentelemetry-http"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
dependencies = [
"async-trait",
"bytes",
"http 1.4.0",
"opentelemetry",
"reqwest 0.12.28",
]
[[package]]
name = "opentelemetry-otlp"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf"
dependencies = [
"http 1.4.0",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost",
"reqwest 0.12.28",
"thiserror 2.0.18",
"tokio",
"tonic",
"tracing",
]
[[package]]
name = "opentelemetry-proto"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"prost",
"tonic",
"tonic-prost",
]
[[package]]
name = "opentelemetry_sdk"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"rand 0.9.2",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2137,6 +2236,40 @@ dependencies = [
"winreg",
]
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper 1.8.1",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper 1.0.2",
"tokio",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -2332,6 +2465,19 @@ dependencies = [
"tracing",
]
[[package]]
name = "serde_html_form"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
dependencies = [
"form_urlencoded",
"indexmap",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -2751,6 +2897,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -3050,12 +3199,14 @@ dependencies = [
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
@@ -3117,6 +3268,22 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-opentelemetry"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc"
dependencies = [
"js-sys",
"opentelemetry",
"smallvec",
"tracing",
"tracing-core",
"tracing-log",
"tracing-subscriber",
"web-time",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
@@ -3204,6 +3371,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3401,6 +3574,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.25.4"

View File

@@ -17,7 +17,7 @@ tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
axum = { version = "0.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie"] }
axum-extra = { version = "0.10", features = ["cookie", "form"] }
minijinja = { version = "2", features = ["loader"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "migrate", "uuid", "chrono"] }
uuid = { version = "1", features = ["v4", "serde"] }
@@ -31,3 +31,7 @@ tonic-prost = "0.14"
async-trait = "0.1"
rand = "0.9"
time = "0.3"
opentelemetry = "0.31"
opentelemetry_sdk = { version = "0.31", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic"] }
tracing-opentelemetry = "0.32"

View File

@@ -20,6 +20,14 @@ pub struct User {
pub emails: Vec<UserEmail>,
}
/// Public user profile (no emails).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfile {
pub user_id: String,
pub username: String,
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserEmail {
pub email: String,
@@ -91,6 +99,12 @@ pub trait ForestAuth: Send + Sync {
async fn get_user(&self, access_token: &str) -> Result<User, AuthError>;
async fn get_user_by_username(
&self,
access_token: &str,
username: &str,
) -> Result<UserProfile, AuthError>;
async fn list_tokens(
&self,
access_token: &str,

View File

@@ -69,6 +69,8 @@ pub struct ArtifactDestination {
pub type_name: Option<String>,
#[serde(default)]
pub type_version: Option<u64>,
#[serde(default)]
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -79,6 +81,16 @@ pub struct OrgMember {
pub joined_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Environment {
pub id: String,
pub organisation: String,
pub name: String,
pub description: Option<String>,
pub sort_order: i32,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Destination {
pub name: String,
@@ -97,6 +109,201 @@ pub struct DestinationType {
pub version: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DestinationState {
pub destination_id: String,
pub destination_name: String,
pub environment: String,
pub release_id: Option<String>,
pub artifact_id: Option<String>,
pub status: Option<String>,
pub error_message: Option<String>,
pub queued_at: Option<String>,
pub completed_at: Option<String>,
pub queue_position: Option<i32>,
#[serde(default)]
pub started_at: Option<String>,
}
/// Runtime status of a single pipeline stage.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineRunStageState {
pub stage_id: String,
pub depends_on: Vec<String>,
pub stage_type: String, // "deploy" or "wait"
pub status: String, // "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"
pub environment: Option<String>,
pub duration_seconds: Option<i64>,
pub queued_at: Option<String>,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub error_message: Option<String>,
pub wait_until: Option<String>,
#[serde(default)]
pub release_ids: Vec<String>,
}
/// Combined response from get_destination_states: destinations only.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DeploymentStates {
pub destinations: Vec<DestinationState>,
}
/// Full state of a release intent: pipeline stages + individual release steps.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseIntentState {
pub release_intent_id: String,
pub artifact_id: String,
pub project: String,
pub created_at: String,
pub stages: Vec<PipelineRunStageState>,
pub steps: Vec<ReleaseStepState>,
}
/// Status of an individual release step (deploy work item).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseStepState {
pub release_id: String,
pub stage_id: Option<String>,
pub destination_name: String,
pub environment: String,
pub status: String,
pub queued_at: Option<String>,
pub assigned_at: Option<String>,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub error_message: Option<String>,
}
// ── Triggers (auto-release triggers) ────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trigger {
pub id: String,
pub name: String,
pub enabled: bool,
pub branch_pattern: Option<String>,
pub title_pattern: Option<String>,
pub author_pattern: Option<String>,
pub commit_message_pattern: Option<String>,
pub source_type_pattern: Option<String>,
pub target_environments: Vec<String>,
pub target_destinations: Vec<String>,
pub force_release: bool,
pub use_pipeline: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTriggerInput {
pub name: String,
pub branch_pattern: Option<String>,
pub title_pattern: Option<String>,
pub author_pattern: Option<String>,
pub commit_message_pattern: Option<String>,
pub source_type_pattern: Option<String>,
pub target_environments: Vec<String>,
pub target_destinations: Vec<String>,
pub force_release: bool,
pub use_pipeline: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateTriggerInput {
pub enabled: Option<bool>,
pub branch_pattern: Option<String>,
pub title_pattern: Option<String>,
pub author_pattern: Option<String>,
pub commit_message_pattern: Option<String>,
pub source_type_pattern: Option<String>,
pub target_environments: Vec<String>,
pub target_destinations: Vec<String>,
pub force_release: Option<bool>,
pub use_pipeline: Option<bool>,
}
// ── Policies (deployment gating) ────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
pub id: String,
pub name: String,
pub enabled: bool,
pub policy_type: String,
pub config: PolicyConfig,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PolicyConfig {
SoakTime {
source_environment: String,
target_environment: String,
duration_seconds: i64,
},
BranchRestriction {
target_environment: String,
branch_pattern: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreatePolicyInput {
pub name: String,
pub config: PolicyConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdatePolicyInput {
pub enabled: Option<bool>,
pub config: Option<PolicyConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyEvaluation {
pub policy_name: String,
pub policy_type: String,
pub passed: bool,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineStage {
pub id: String,
pub depends_on: Vec<String>,
pub config: PipelineStageConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PipelineStageConfig {
Deploy { environment: String },
Wait { duration_seconds: i64 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleasePipeline {
pub id: String,
pub name: String,
pub enabled: bool,
pub stages: Vec<PipelineStage>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateReleasePipelineInput {
pub name: String,
pub stages: Vec<PipelineStage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateReleasePipelineInput {
pub enabled: Option<bool>,
pub stages: Option<Vec<PipelineStage>>,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum PlatformError {
#[error("not authenticated")]
@@ -175,11 +382,170 @@ pub trait ForestPlatform: Send + Sync {
slug: &str,
) -> Result<Artifact, PlatformError>;
async fn list_environments(
&self,
access_token: &str,
organisation: &str,
) -> Result<Vec<Environment>, PlatformError>;
async fn list_destinations(
&self,
access_token: &str,
organisation: &str,
) -> Result<Vec<Destination>, PlatformError>;
async fn create_environment(
&self,
access_token: &str,
organisation: &str,
name: &str,
description: Option<&str>,
sort_order: i32,
) -> Result<Environment, PlatformError>;
async fn create_destination(
&self,
access_token: &str,
organisation: &str,
name: &str,
environment: &str,
metadata: &std::collections::HashMap<String, String>,
dest_type: Option<&DestinationType>,
) -> Result<(), PlatformError>;
async fn update_destination(
&self,
access_token: &str,
name: &str,
metadata: &std::collections::HashMap<String, String>,
) -> Result<(), PlatformError>;
async fn get_destination_states(
&self,
access_token: &str,
organisation: &str,
project: Option<&str>,
) -> Result<DeploymentStates, PlatformError>;
async fn get_release_intent_states(
&self,
access_token: &str,
organisation: &str,
project: Option<&str>,
include_completed: bool,
) -> Result<Vec<ReleaseIntentState>, PlatformError>;
async fn release_artifact(
&self,
access_token: &str,
artifact_id: &str,
destinations: &[String],
environments: &[String],
use_pipeline: bool,
) -> Result<(), PlatformError>;
async fn list_triggers(
&self,
access_token: &str,
organisation: &str,
project: &str,
) -> Result<Vec<Trigger>, PlatformError>;
async fn create_trigger(
&self,
access_token: &str,
organisation: &str,
project: &str,
input: &CreateTriggerInput,
) -> Result<Trigger, PlatformError>;
async fn update_trigger(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
input: &UpdateTriggerInput,
) -> Result<Trigger, PlatformError>;
async fn delete_trigger(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
) -> Result<(), PlatformError>;
async fn list_policies(
&self,
access_token: &str,
organisation: &str,
project: &str,
) -> Result<Vec<Policy>, PlatformError>;
async fn create_policy(
&self,
access_token: &str,
organisation: &str,
project: &str,
input: &CreatePolicyInput,
) -> Result<Policy, PlatformError>;
async fn update_policy(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
input: &UpdatePolicyInput,
) -> Result<Policy, PlatformError>;
async fn delete_policy(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
) -> Result<(), PlatformError>;
async fn list_release_pipelines(
&self,
access_token: &str,
organisation: &str,
project: &str,
) -> Result<Vec<ReleasePipeline>, PlatformError>;
async fn create_release_pipeline(
&self,
access_token: &str,
organisation: &str,
project: &str,
input: &CreateReleasePipelineInput,
) -> Result<ReleasePipeline, PlatformError>;
async fn update_release_pipeline(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
input: &UpdateReleasePipelineInput,
) -> Result<ReleasePipeline, PlatformError>;
async fn delete_release_pipeline(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
) -> Result<(), PlatformError>;
/// Get the spec (forest.cue) content for an artifact. Returns empty string if no spec was uploaded.
async fn get_artifact_spec(
&self,
access_token: &str,
artifact_id: &str,
) -> Result<String, PlatformError>;
}
#[cfg(test)]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -24,3 +24,10 @@ tracing.workspace = true
tracing-subscriber.workspace = true
time.workspace = true
uuid.workspace = true
urlencoding = "2.1.3"
opentelemetry.workspace = true
opentelemetry_sdk.workspace = true
opentelemetry-otlp.workspace = true
tracing-opentelemetry.workspace = true
futures-util = "0.3"
tokio-stream = "0.1"

View File

@@ -1,10 +1,19 @@
use forage_core::auth::{
AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail,
UserProfile,
};
use forage_core::platform::{
Artifact, ArtifactContext, ArtifactDestination, ArtifactSource, Destination, ForestPlatform,
Organisation, OrgMember, PlatformError,
Artifact, ArtifactContext, ArtifactDestination, ArtifactRef, ArtifactSource, CreatePolicyInput,
CreateReleasePipelineInput, CreateTriggerInput, Destination, DestinationType, Environment,
ForestPlatform, Organisation, OrgMember, PipelineStage, PipelineStageConfig, PlatformError,
Policy, PolicyConfig, ReleasePipeline, Trigger, UpdatePolicyInput,
UpdateReleasePipelineInput, UpdateTriggerInput,
};
use forage_grpc::policy_service_client::PolicyServiceClient;
use forage_grpc::release_pipeline_service_client::ReleasePipelineServiceClient;
use forage_grpc::trigger_service_client::TriggerServiceClient;
use forage_grpc::destination_service_client::DestinationServiceClient;
use forage_grpc::environment_service_client::EnvironmentServiceClient;
use forage_grpc::organisation_service_client::OrganisationServiceClient;
use forage_grpc::release_service_client::ReleaseServiceClient;
use forage_grpc::users_service_client::UsersServiceClient;
@@ -42,10 +51,42 @@ impl GrpcForestClient {
OrganisationServiceClient::new(self.channel.clone())
}
fn release_client(&self) -> ReleaseServiceClient<Channel> {
pub(crate) fn artifact_client(
&self,
) -> forage_grpc::artifact_service_client::ArtifactServiceClient<Channel> {
forage_grpc::artifact_service_client::ArtifactServiceClient::new(self.channel.clone())
}
pub(crate) fn release_client(&self) -> ReleaseServiceClient<Channel> {
ReleaseServiceClient::new(self.channel.clone())
}
fn env_client(&self) -> EnvironmentServiceClient<Channel> {
EnvironmentServiceClient::new(self.channel.clone())
}
fn dest_client(&self) -> DestinationServiceClient<Channel> {
DestinationServiceClient::new(self.channel.clone())
}
fn trigger_client(&self) -> TriggerServiceClient<Channel> {
TriggerServiceClient::new(self.channel.clone())
}
fn policy_client(&self) -> PolicyServiceClient<Channel> {
PolicyServiceClient::new(self.channel.clone())
}
fn pipeline_client(&self) -> ReleasePipelineServiceClient<Channel> {
ReleasePipelineServiceClient::new(self.channel.clone())
}
pub fn event_client(
&self,
) -> forage_grpc::event_service_client::EventServiceClient<Channel> {
forage_grpc::event_service_client::EventServiceClient::new(self.channel.clone())
}
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
bearer_request(access_token, msg).map_err(AuthError::Other)
}
@@ -202,6 +243,41 @@ impl ForestAuth for GrpcForestClient {
Ok(convert_user(user))
}
async fn get_user_by_username(
&self,
access_token: &str,
username: &str,
) -> Result<UserProfile, AuthError> {
let req = Self::authed_request(
access_token,
forage_grpc::GetUserRequest {
identifier: Some(forage_grpc::get_user_request::Identifier::Username(
username.into(),
)),
},
)?;
let resp = self
.client()
.get_user(req)
.await
.map_err(map_status)?
.into_inner();
let user = resp
.user
.ok_or(AuthError::Other("no user in response".into()))?;
Ok(UserProfile {
user_id: user.user_id,
username: user.username,
created_at: user.created_at.map(|ts| {
chrono::DateTime::from_timestamp(ts.seconds, ts.nanos as u32)
.map(|dt| dt.to_rfc3339())
.unwrap_or_default()
}),
})
}
async fn list_tokens(
&self,
access_token: &str,
@@ -396,8 +472,13 @@ fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
source_type: s.source_type.filter(|v| !v.is_empty()),
run_url: s.run_url.filter(|v| !v.is_empty()),
});
// Artifact proto does not carry git ref directly; git info comes from AnnotateRelease.
// We leave git_ref as None for now.
let git_ref = a.r#ref.map(|r| ArtifactRef {
commit_sha: r.commit_sha,
branch: r.branch.filter(|v| !v.is_empty()),
commit_message: r.commit_message.filter(|v| !v.is_empty()),
version: r.version.filter(|v| !v.is_empty()),
repo_url: r.repo_url.filter(|v| !v.is_empty()),
});
let destinations = a
.destinations
.into_iter()
@@ -419,6 +500,11 @@ fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
} else {
Some(d.type_version)
},
status: if d.status.is_empty() {
None
} else {
Some(d.status)
},
})
.collect();
Artifact {
@@ -435,12 +521,202 @@ fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
pr: ctx.pr.filter(|v| !v.is_empty()),
},
source,
git_ref: None,
git_ref,
destinations,
created_at: a.created_at,
}
}
fn convert_pipeline_stage(s: forage_grpc::PipelineStage) -> PipelineStage {
let config = match s.config {
Some(forage_grpc::pipeline_stage::Config::Deploy(d)) => {
PipelineStageConfig::Deploy { environment: d.environment }
}
Some(forage_grpc::pipeline_stage::Config::Wait(w)) => {
PipelineStageConfig::Wait { duration_seconds: w.duration_seconds }
}
None => PipelineStageConfig::Deploy { environment: String::new() },
};
PipelineStage {
id: s.id,
depends_on: s.depends_on,
config,
}
}
/// Convert a `PipelineStageState` proto message (from GetReleaseIntentStates)
/// to the domain type. Same enum mapping as `convert_pipeline_run_stage`.
fn convert_pipeline_stage_state(
s: forage_grpc::PipelineStageState,
) -> forage_core::platform::PipelineRunStageState {
let stage_type = match forage_grpc::PipelineRunStageType::try_from(s.stage_type) {
Ok(forage_grpc::PipelineRunStageType::Deploy) => "deploy",
Ok(forage_grpc::PipelineRunStageType::Wait) => "wait",
_ => "unknown",
};
let status = match forage_grpc::PipelineRunStageStatus::try_from(s.status) {
Ok(forage_grpc::PipelineRunStageStatus::Pending) => "PENDING",
Ok(forage_grpc::PipelineRunStageStatus::Active) => "RUNNING",
Ok(forage_grpc::PipelineRunStageStatus::Succeeded) => "SUCCEEDED",
Ok(forage_grpc::PipelineRunStageStatus::Failed) => "FAILED",
Ok(forage_grpc::PipelineRunStageStatus::Cancelled) => "CANCELLED",
_ => "PENDING",
};
forage_core::platform::PipelineRunStageState {
stage_id: s.stage_id,
depends_on: s.depends_on,
stage_type: stage_type.into(),
status: status.into(),
environment: s.environment,
duration_seconds: s.duration_seconds,
queued_at: s.queued_at,
started_at: s.started_at,
completed_at: s.completed_at,
error_message: s.error_message,
wait_until: s.wait_until,
release_ids: s.release_ids,
}
}
fn convert_release_step_state(
s: forage_grpc::ReleaseStepState,
) -> forage_core::platform::ReleaseStepState {
forage_core::platform::ReleaseStepState {
release_id: s.release_id,
stage_id: s.stage_id,
destination_name: s.destination_name,
environment: s.environment,
status: s.status,
queued_at: s.queued_at,
assigned_at: s.assigned_at,
started_at: s.started_at,
completed_at: s.completed_at,
error_message: s.error_message,
}
}
fn convert_stages_to_grpc(stages: &[PipelineStage]) -> Vec<forage_grpc::PipelineStage> {
stages
.iter()
.map(|s| forage_grpc::PipelineStage {
id: s.id.clone(),
depends_on: s.depends_on.clone(),
config: Some(match &s.config {
PipelineStageConfig::Deploy { environment } => {
forage_grpc::pipeline_stage::Config::Deploy(forage_grpc::DeployStageConfig {
environment: environment.clone(),
})
}
PipelineStageConfig::Wait { duration_seconds } => {
forage_grpc::pipeline_stage::Config::Wait(forage_grpc::WaitStageConfig {
duration_seconds: *duration_seconds,
})
}
}),
})
.collect()
}
fn convert_release_pipeline(p: forage_grpc::ReleasePipeline) -> ReleasePipeline {
ReleasePipeline {
id: p.id,
name: p.name,
enabled: p.enabled,
stages: p.stages.into_iter().map(convert_pipeline_stage).collect(),
created_at: p.created_at,
updated_at: p.updated_at,
}
}
fn convert_trigger(t: forage_grpc::Trigger) -> Trigger {
Trigger {
id: t.id,
name: t.name,
enabled: t.enabled,
branch_pattern: t.branch_pattern,
title_pattern: t.title_pattern,
author_pattern: t.author_pattern,
commit_message_pattern: t.commit_message_pattern,
source_type_pattern: t.source_type_pattern,
target_environments: t.target_environments,
target_destinations: t.target_destinations,
force_release: t.force_release,
use_pipeline: t.use_pipeline,
created_at: t.created_at,
updated_at: t.updated_at,
}
}
fn convert_policy(p: forage_grpc::Policy) -> Policy {
let policy_type_str = match forage_grpc::PolicyType::try_from(p.policy_type) {
Ok(forage_grpc::PolicyType::SoakTime) => "soak_time",
Ok(forage_grpc::PolicyType::BranchRestriction) => "branch_restriction",
_ => "unknown",
};
let config = match p.config {
Some(forage_grpc::policy::Config::SoakTime(c)) => PolicyConfig::SoakTime {
source_environment: c.source_environment,
target_environment: c.target_environment,
duration_seconds: c.duration_seconds,
},
Some(forage_grpc::policy::Config::BranchRestriction(c)) => {
PolicyConfig::BranchRestriction {
target_environment: c.target_environment,
branch_pattern: c.branch_pattern,
}
}
None => PolicyConfig::SoakTime {
source_environment: String::new(),
target_environment: String::new(),
duration_seconds: 0,
},
};
Policy {
id: p.id,
name: p.name,
enabled: p.enabled,
policy_type: policy_type_str.into(),
config,
created_at: p.created_at,
updated_at: p.updated_at,
}
}
fn policy_config_to_grpc(
config: &PolicyConfig,
) -> (i32, Option<forage_grpc::create_policy_request::Config>) {
match config {
PolicyConfig::SoakTime {
source_environment,
target_environment,
duration_seconds,
} => (
forage_grpc::PolicyType::SoakTime as i32,
Some(forage_grpc::create_policy_request::Config::SoakTime(
forage_grpc::SoakTimeConfig {
source_environment: source_environment.clone(),
target_environment: target_environment.clone(),
duration_seconds: *duration_seconds,
},
)),
),
PolicyConfig::BranchRestriction {
target_environment,
branch_pattern,
} => (
forage_grpc::PolicyType::BranchRestriction as i32,
Some(
forage_grpc::create_policy_request::Config::BranchRestriction(
forage_grpc::BranchRestrictionConfig {
target_environment: target_environment.clone(),
branch_pattern: branch_pattern.clone(),
},
),
),
),
}
}
fn convert_member(m: forage_grpc::OrganisationMember) -> OrgMember {
OrgMember {
user_id: m.user_id,
@@ -688,13 +964,661 @@ impl ForestPlatform for GrpcForestClient {
Ok(convert_artifact(artifact))
}
async fn list_environments(
&self,
access_token: &str,
organisation: &str,
) -> Result<Vec<Environment>, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::ListEnvironmentsRequest {
organisation: organisation.into(),
},
)?;
let resp = self
.env_client()
.list_environments(req)
.await
.map_err(map_platform_status)?
.into_inner();
Ok(resp
.environments
.into_iter()
.map(|e| Environment {
id: e.id,
organisation: e.organisation,
name: e.name,
description: e.description.filter(|v| !v.is_empty()),
sort_order: e.sort_order,
created_at: e.created_at,
})
.collect())
}
async fn list_destinations(
&self,
_access_token: &str,
_organisation: &str,
access_token: &str,
organisation: &str,
) -> Result<Vec<Destination>, PlatformError> {
// DestinationService client not yet generated; return empty for now
Ok(vec![])
let req = platform_authed_request(
access_token,
forage_grpc::GetDestinationsRequest {
organisation: organisation.into(),
},
)?;
let resp = self
.dest_client()
.get_destinations(req)
.await
.map_err(map_platform_status)?
.into_inner();
Ok(resp
.destinations
.into_iter()
.map(|d| Destination {
name: d.name,
environment: d.environment,
organisation: d.organisation,
metadata: d.metadata,
dest_type: d.r#type.map(|t| DestinationType {
organisation: t.organisation,
name: t.name,
version: t.version,
}),
})
.collect())
}
async fn create_environment(
&self,
access_token: &str,
organisation: &str,
name: &str,
description: Option<&str>,
sort_order: i32,
) -> Result<Environment, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::CreateEnvironmentRequest {
organisation: organisation.into(),
name: name.into(),
description: description.map(|s| s.to_string()),
sort_order,
},
)?;
let resp = self
.env_client()
.create_environment(req)
.await
.map_err(map_platform_status)?
.into_inner();
let e = resp
.environment
.ok_or(PlatformError::Other("no environment in response".into()))?;
Ok(Environment {
id: e.id,
organisation: e.organisation,
name: e.name,
description: e.description.filter(|v| !v.is_empty()),
sort_order: e.sort_order,
created_at: e.created_at,
})
}
async fn create_destination(
&self,
access_token: &str,
organisation: &str,
name: &str,
environment: &str,
metadata: &std::collections::HashMap<String, String>,
dest_type: Option<&forage_core::platform::DestinationType>,
) -> Result<(), PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::CreateDestinationRequest {
organisation: organisation.into(),
name: name.into(),
environment: environment.into(),
metadata: metadata.clone(),
r#type: dest_type.map(|t| forage_grpc::DestinationType {
organisation: t.organisation.clone(),
name: t.name.clone(),
version: t.version,
}),
},
)?;
self.dest_client()
.create_destination(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn update_destination(
&self,
access_token: &str,
name: &str,
metadata: &std::collections::HashMap<String, String>,
) -> Result<(), PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::UpdateDestinationRequest {
name: name.into(),
metadata: metadata.clone(),
},
)?;
self.dest_client()
.update_destination(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn get_destination_states(
&self,
access_token: &str,
organisation: &str,
project: Option<&str>,
) -> Result<forage_core::platform::DeploymentStates, PlatformError> {
let req = bearer_request(
access_token,
forage_grpc::GetDestinationStatesRequest {
organisation: organisation.into(),
project: project.map(|p| p.into()),
},
)
.map_err(|e| PlatformError::Other(e.to_string()))?;
let resp = self
.release_client()
.get_destination_states(req)
.await
.map_err(map_platform_status)?;
let inner = resp.into_inner();
let destinations = inner
.destinations
.into_iter()
.map(|d| forage_core::platform::DestinationState {
destination_id: d.destination_id,
destination_name: d.destination_name,
environment: d.environment,
release_id: d.release_id,
artifact_id: d.artifact_id,
status: d.status,
error_message: d.error_message,
queued_at: d.queued_at,
completed_at: d.completed_at,
queue_position: d.queue_position,
started_at: d.started_at,
})
.collect();
Ok(forage_core::platform::DeploymentStates {
destinations,
})
}
async fn get_release_intent_states(
&self,
access_token: &str,
organisation: &str,
project: Option<&str>,
include_completed: bool,
) -> Result<Vec<forage_core::platform::ReleaseIntentState>, PlatformError> {
let req = bearer_request(
access_token,
forage_grpc::GetReleaseIntentStatesRequest {
organisation: organisation.into(),
project: project.map(|p| p.into()),
include_completed,
},
)
.map_err(|e| PlatformError::Other(e.to_string()))?;
let resp = self
.release_client()
.get_release_intent_states(req)
.await
.map_err(map_platform_status)?;
Ok(resp
.into_inner()
.release_intents
.into_iter()
.map(|ri| forage_core::platform::ReleaseIntentState {
release_intent_id: ri.release_intent_id,
artifact_id: ri.artifact_id,
project: ri.project,
created_at: ri.created_at,
stages: ri
.stages
.into_iter()
.map(convert_pipeline_stage_state)
.collect(),
steps: ri
.steps
.into_iter()
.map(convert_release_step_state)
.collect(),
})
.collect())
}
async fn release_artifact(
&self,
access_token: &str,
artifact_id: &str,
destinations: &[String],
environments: &[String],
use_pipeline: bool,
) -> Result<(), PlatformError> {
let req = bearer_request(
access_token,
forage_grpc::ReleaseRequest {
artifact_id: artifact_id.into(),
destinations: destinations.to_vec(),
environments: environments.to_vec(),
force: false,
use_pipeline,
},
)
.map_err(|e| PlatformError::Other(e.to_string()))?;
self.release_client()
.release(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn list_triggers(
&self,
access_token: &str,
organisation: &str,
project: &str,
) -> Result<Vec<Trigger>, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::ListTriggersRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
},
)?;
let resp = self
.trigger_client()
.list_triggers(req)
.await
.map_err(map_platform_status)?
.into_inner();
Ok(resp.triggers.into_iter().map(convert_trigger).collect())
}
async fn create_trigger(
&self,
access_token: &str,
organisation: &str,
project: &str,
input: &CreateTriggerInput,
) -> Result<Trigger, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::CreateTriggerRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
name: input.name.clone(),
branch_pattern: input.branch_pattern.clone(),
title_pattern: input.title_pattern.clone(),
author_pattern: input.author_pattern.clone(),
commit_message_pattern: input.commit_message_pattern.clone(),
source_type_pattern: input.source_type_pattern.clone(),
target_environments: input.target_environments.clone(),
target_destinations: input.target_destinations.clone(),
force_release: input.force_release,
use_pipeline: input.use_pipeline,
},
)?;
let resp = self
.trigger_client()
.create_trigger(req)
.await
.map_err(map_platform_status)?
.into_inner();
let trigger = resp
.trigger
.ok_or(PlatformError::Other("no trigger in response".into()))?;
Ok(convert_trigger(trigger))
}
async fn update_trigger(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
input: &UpdateTriggerInput,
) -> Result<Trigger, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::UpdateTriggerRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
name: name.into(),
enabled: input.enabled,
branch_pattern: input.branch_pattern.clone(),
title_pattern: input.title_pattern.clone(),
author_pattern: input.author_pattern.clone(),
commit_message_pattern: input.commit_message_pattern.clone(),
source_type_pattern: input.source_type_pattern.clone(),
target_environments: input.target_environments.clone(),
target_destinations: input.target_destinations.clone(),
force_release: input.force_release,
use_pipeline: input.use_pipeline,
},
)?;
let resp = self
.trigger_client()
.update_trigger(req)
.await
.map_err(map_platform_status)?
.into_inner();
let trigger = resp
.trigger
.ok_or(PlatformError::Other("no trigger in response".into()))?;
Ok(convert_trigger(trigger))
}
async fn delete_trigger(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
) -> Result<(), PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::DeleteTriggerRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
name: name.into(),
},
)?;
self.trigger_client()
.delete_trigger(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn list_policies(
&self,
access_token: &str,
organisation: &str,
project: &str,
) -> Result<Vec<Policy>, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::ListPoliciesRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
},
)?;
let resp = self
.policy_client()
.list_policies(req)
.await
.map_err(map_platform_status)?
.into_inner();
Ok(resp.policies.into_iter().map(convert_policy).collect())
}
async fn create_policy(
&self,
access_token: &str,
organisation: &str,
project: &str,
input: &CreatePolicyInput,
) -> Result<Policy, PlatformError> {
let (policy_type, config) = policy_config_to_grpc(&input.config);
let req = platform_authed_request(
access_token,
forage_grpc::CreatePolicyRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
name: input.name.clone(),
policy_type,
config,
},
)?;
let resp = self
.policy_client()
.create_policy(req)
.await
.map_err(map_platform_status)?
.into_inner();
let policy = resp
.policy
.ok_or(PlatformError::Other("no policy in response".into()))?;
Ok(convert_policy(policy))
}
async fn update_policy(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
input: &UpdatePolicyInput,
) -> Result<Policy, PlatformError> {
let config = input.config.as_ref().map(|c| {
let (_, grpc_config) = policy_config_to_grpc(c);
match grpc_config {
Some(forage_grpc::create_policy_request::Config::SoakTime(s)) => {
forage_grpc::update_policy_request::Config::SoakTime(s)
}
Some(forage_grpc::create_policy_request::Config::BranchRestriction(b)) => {
forage_grpc::update_policy_request::Config::BranchRestriction(b)
}
None => forage_grpc::update_policy_request::Config::SoakTime(
forage_grpc::SoakTimeConfig::default(),
),
}
});
let req = platform_authed_request(
access_token,
forage_grpc::UpdatePolicyRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
name: name.into(),
enabled: input.enabled,
config,
},
)?;
let resp = self
.policy_client()
.update_policy(req)
.await
.map_err(map_platform_status)?
.into_inner();
let policy = resp
.policy
.ok_or(PlatformError::Other("no policy in response".into()))?;
Ok(convert_policy(policy))
}
async fn delete_policy(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
) -> Result<(), PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::DeletePolicyRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
name: name.into(),
},
)?;
self.policy_client()
.delete_policy(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn list_release_pipelines(
&self,
access_token: &str,
organisation: &str,
project: &str,
) -> Result<Vec<ReleasePipeline>, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::ListReleasePipelinesRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
},
)?;
let resp = self
.pipeline_client()
.list_release_pipelines(req)
.await
.map_err(map_platform_status)?
.into_inner();
Ok(resp
.pipelines
.into_iter()
.map(convert_release_pipeline)
.collect())
}
async fn create_release_pipeline(
&self,
access_token: &str,
organisation: &str,
project: &str,
input: &CreateReleasePipelineInput,
) -> Result<ReleasePipeline, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::CreateReleasePipelineRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
name: input.name.clone(),
stages: convert_stages_to_grpc(&input.stages),
},
)?;
let resp = self
.pipeline_client()
.create_release_pipeline(req)
.await
.map_err(map_platform_status)?
.into_inner();
let pipeline = resp
.pipeline
.ok_or(PlatformError::Other("no pipeline in response".into()))?;
Ok(convert_release_pipeline(pipeline))
}
async fn update_release_pipeline(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
input: &UpdateReleasePipelineInput,
) -> Result<ReleasePipeline, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::UpdateReleasePipelineRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
name: name.into(),
enabled: input.enabled,
stages: input.stages.as_ref().map(|s| convert_stages_to_grpc(s)).unwrap_or_default(),
update_stages: input.stages.is_some(),
},
)?;
let resp = self
.pipeline_client()
.update_release_pipeline(req)
.await
.map_err(map_platform_status)?
.into_inner();
let pipeline = resp
.pipeline
.ok_or(PlatformError::Other("no pipeline in response".into()))?;
Ok(convert_release_pipeline(pipeline))
}
async fn delete_release_pipeline(
&self,
access_token: &str,
organisation: &str,
project: &str,
name: &str,
) -> Result<(), PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::DeleteReleasePipelineRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
name: name.into(),
},
)?;
self.pipeline_client()
.delete_release_pipeline(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn get_artifact_spec(
&self,
access_token: &str,
artifact_id: &str,
) -> Result<String, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::GetArtifactSpecRequest {
artifact_id: artifact_id.into(),
},
)?;
let resp = self
.artifact_client()
.get_artifact_spec(req)
.await
.map_err(map_platform_status)?;
Ok(resp.into_inner().content)
}
}

View File

@@ -8,29 +8,94 @@ use std::net::SocketAddr;
use std::sync::Arc;
use axum::Router;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use forage_core::session::{FileSessionStore, SessionStore};
use forage_db::PgSessionStore;
use minijinja::context;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use opentelemetry::trace::TracerProvider as _;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use crate::forest_client::GrpcForestClient;
use crate::state::AppState;
use crate::templates::TemplateEngine;
fn init_telemetry() {
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,h2=warn,tonic=info".into());
let fmt_layer = tracing_subscriber::fmt::layer();
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok() {
// OTLP exporter configured — send spans + logs to collector
let tracer = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.build()
.expect("failed to create OTLP span exporter");
let tracer_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
.with_batch_exporter(tracer)
.with_resource(
opentelemetry_sdk::Resource::builder()
.with_service_name(
std::env::var("OTEL_SERVICE_NAME")
.unwrap_or_else(|_| "forage-server".into()),
)
.build(),
)
.build();
let otel_layer = tracing_opentelemetry::layer()
.with_tracer(tracer_provider.tracer("forage-server"));
tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer)
.with(otel_layer)
.init();
tracing::info!("OpenTelemetry enabled — exporting to OTLP endpoint");
} else {
tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer)
.init();
}
}
async fn fallback_404(State(state): State<AppState>) -> Response {
let html = state.templates.render(
"pages/error.html.jinja",
context! {
title => "Not Found - Forage",
description => "The page you're looking for doesn't exist.",
status => 404u16,
heading => "Page not found",
message => "The page you're looking for doesn't exist.",
},
);
match html {
Ok(body) => (StatusCode::NOT_FOUND, Html(body)).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
pub fn build_router(state: AppState) -> Router {
Router::new()
.merge(routes::router())
.nest_service("/static", ServeDir::new("static"))
.fallback(fallback_404)
.layer(TraceLayer::new_for_http())
.with_state(state)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
init_telemetry();
let forest_endpoint =
std::env::var("FOREST_SERVER_URL").unwrap_or_else(|_| "http://localhost:4040".into());
@@ -81,7 +146,8 @@ async fn main() -> anyhow::Result<()> {
};
let forest_client = Arc::new(forest_client);
let state = AppState::new(template_engine, forest_client.clone(), forest_client, sessions);
let state = AppState::new(template_engine, forest_client.clone(), forest_client.clone(), sessions)
.with_grpc_client(forest_client);
let app = build_router(state);
let port: u16 = std::env::var("PORT")

View File

@@ -7,7 +7,7 @@ use chrono::Utc;
use minijinja::context;
use serde::Deserialize;
use super::error_page;
use super::{error_page, internal_error};
use crate::auth::{self, MaybeSession, Session};
use crate::state::AppState;
use forage_core::auth::{validate_email, validate_password, validate_username, UserEmail};
@@ -390,8 +390,7 @@ async fn tokens_page(
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
internal_error(&state, "template error", &e)
})?;
Ok(Html(html).into_response())
@@ -422,8 +421,7 @@ async fn create_token_submit(
.create_token(&session.access_token, &session.user.user_id, &form.name)
.await
.map_err(|e| {
tracing::error!("failed to create token: {e}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
internal_error(&state, "failed to create token", &e)
})?;
let tokens = state
@@ -455,8 +453,7 @@ async fn create_token_submit(
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
internal_error(&state, "template error", &e)
})?;
Ok(Html(html).into_response())
@@ -477,8 +474,7 @@ async fn delete_token_submit(
.delete_token(&session.access_token, &token_id)
.await
.map_err(|e| {
tracing::error!("failed to delete token: {e}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
internal_error(&state, "failed to delete token", &e)
})?;
Ok(Redirect::to("/settings/tokens").into_response())
@@ -522,13 +518,7 @@ fn render_account(
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(
state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
internal_error(state, "template error", &e)
})?;
Ok(Html(html).into_response())

View File

@@ -0,0 +1,312 @@
use axum::extract::{Path, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use forage_core::platform::validate_slug;
use futures_util::StreamExt;
use std::convert::Infallible;
use tokio_stream::wrappers::ReceiverStream;
use crate::auth::Session;
use crate::forest_client::GrpcForestClient;
use crate::state::AppState;
use super::error_page;
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/orgs/{org}/projects/{project}/events",
get(project_events_sse),
)
.route(
"/api/orgs/{org}/projects/{project}/releases/{slug}/logs",
get(release_logs_sse),
)
}
async fn project_events_sse(
State(state): State<AppState>,
session: Session,
Path((org, project)): Path<(String, String)>,
) -> Result<Response, Response> {
// Validate access
let orgs = &session.user.orgs;
if !orgs.iter().any(|o| o.name == org) {
return Err(error_page(
&state,
axum::http::StatusCode::FORBIDDEN,
"Access denied",
"You are not a member of this organisation.",
));
}
if !validate_slug(&project) {
return Err(error_page(
&state,
axum::http::StatusCode::BAD_REQUEST,
"Invalid request",
"Invalid project name.",
));
}
let grpc_client = state.grpc_client.as_ref().ok_or_else(|| {
error_page(
&state,
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"Service unavailable",
"Event streaming is not available.",
)
})?;
let access_token = session.access_token.clone();
let mut event_client = grpc_client.event_client();
let mut req = tonic::Request::new(forage_grpc::SubscribeEventsRequest {
organisation: org.clone(),
project: project.clone(),
resource_types: vec![],
actions: vec![],
since_sequence: 0,
});
let bearer: tonic::metadata::MetadataValue<_> = format!("Bearer {access_token}")
.parse()
.map_err(|_| {
error_page(
&state,
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Internal error",
"Failed to create auth header.",
)
})?;
req.metadata_mut().insert("authorization", bearer);
let grpc_stream = event_client.subscribe(req).await.map_err(|e| {
tracing::error!("event subscribe failed: {e}");
error_page(
&state,
axum::http::StatusCode::BAD_GATEWAY,
"Connection failed",
"Could not connect to event stream.",
)
})?;
let mut grpc_stream = grpc_stream.into_inner();
// Bridge gRPC stream -> SSE via a channel
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(32);
tokio::spawn(async move {
while let Some(result) = grpc_stream.next().await {
match result {
Ok(event) => {
let data = serde_json::json!({
"sequence": event.sequence,
"event_id": event.event_id,
"timestamp": event.timestamp,
"organisation": event.organisation,
"project": event.project,
"resource_type": event.resource_type,
"action": event.action,
"resource_id": event.resource_id,
"metadata": event.metadata,
});
let sse_event = Event::default()
.event(&event.resource_type)
.data(data.to_string())
.id(event.sequence.to_string());
if tx.send(Ok(sse_event)).await.is_err() {
break; // Client disconnected
}
}
Err(e) => {
tracing::warn!("event stream error: {e}");
break;
}
}
}
});
let stream = ReceiverStream::new(rx);
let sse = Sse::new(stream).keep_alive(KeepAlive::default());
Ok(sse.into_response())
}
// ─── Release logs SSE ────────────────────────────────────────────────
async fn release_logs_sse(
State(state): State<AppState>,
session: Session,
Path((org, project, slug)): Path<(String, String, String)>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
if !orgs.iter().any(|o| o.name == org) {
return Err(error_page(
&state,
axum::http::StatusCode::FORBIDDEN,
"Access denied",
"You are not a member of this organisation.",
));
}
if !validate_slug(&project) {
return Err(error_page(
&state,
axum::http::StatusCode::BAD_REQUEST,
"Invalid request",
"Invalid project name.",
));
}
let grpc_client = state.grpc_client.as_ref().ok_or_else(|| {
error_page(
&state,
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"Service unavailable",
"Log streaming is not available.",
)
})?;
let access_token = session.access_token.clone();
// Fetch the artifact to get its artifact_id.
let artifact = state
.platform_client
.get_artifact_by_slug(&access_token, &slug)
.await
.map_err(|e| {
tracing::error!("release_logs_sse get_artifact_by_slug: {e}");
error_page(
&state,
axum::http::StatusCode::NOT_FOUND,
"Not found",
"Release not found.",
)
})?;
// Fetch release intent states to find intent IDs for this artifact.
let release_intents = state
.platform_client
.get_release_intent_states(&access_token, &org, Some(&project), true)
.await
.unwrap_or_default();
let intent_ids: Vec<String> = release_intents
.into_iter()
.filter(|ri| ri.artifact_id == artifact.artifact_id)
.map(|ri| ri.release_intent_id)
.collect();
if intent_ids.is_empty() {
// No release intents — return an SSE stream that sends a "done" event and closes.
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(1);
tokio::spawn(async move {
let _ = tx
.send(Ok(Event::default()
.event("done")
.data(r#"{"message":"no logs"}"#)))
.await;
});
let stream = ReceiverStream::new(rx);
return Ok(Sse::new(stream).keep_alive(KeepAlive::default()).into_response());
}
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(128);
// Spawn a WaitRelease stream for each release intent.
for intent_id in intent_ids {
let grpc = grpc_client.clone();
let token = access_token.clone();
let tx = tx.clone();
tokio::spawn(async move {
if let Err(e) = stream_release_logs(&grpc, &token, &intent_id, &tx).await {
tracing::warn!("release log stream for {intent_id}: {e}");
}
});
}
// Drop our copy of tx so the stream ends when all spawned tasks finish.
drop(tx);
let stream = ReceiverStream::new(rx);
let sse = Sse::new(stream).keep_alive(KeepAlive::default());
Ok(sse.into_response())
}
async fn stream_release_logs(
grpc: &GrpcForestClient,
access_token: &str,
release_intent_id: &str,
tx: &tokio::sync::mpsc::Sender<Result<Event, Infallible>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut client = grpc.release_client();
let mut req = tonic::Request::new(forage_grpc::WaitReleaseRequest {
release_intent_id: release_intent_id.to_string(),
});
let bearer: tonic::metadata::MetadataValue<_> =
format!("Bearer {access_token}").parse()?;
req.metadata_mut().insert("authorization", bearer);
let resp = client.wait_release(req).await?;
let mut stream = resp.into_inner();
while let Some(result) = stream.next().await {
match result {
Ok(event) => {
let sse_event = match event.event {
Some(forage_grpc::wait_release_event::Event::LogLine(log)) => {
let channel = match log.channel {
1 => "stdout",
2 => "stderr",
_ => "stdout",
};
let data = serde_json::json!({
"destination": log.destination,
"line": log.line,
"timestamp": log.timestamp,
"channel": channel,
});
Some(Event::default().event("log").data(data.to_string()))
}
Some(forage_grpc::wait_release_event::Event::StatusUpdate(su)) => {
let data = serde_json::json!({
"destination": su.destination,
"status": su.status,
});
Some(Event::default().event("status").data(data.to_string()))
}
Some(forage_grpc::wait_release_event::Event::StageUpdate(su)) => {
let data = serde_json::json!({
"stage_id": su.stage_id,
"stage_type": su.stage_type,
"status": su.status,
});
Some(Event::default().event("stage").data(data.to_string()))
}
None => None,
};
if let Some(sse_event) = sse_event {
if tx.send(Ok(sse_event)).await.is_err() {
return Ok(()); // Client disconnected
}
}
}
Err(e) => {
tracing::warn!("wait_release stream error: {e}");
break;
}
}
}
// Signal that this intent's stream is done.
let _ = tx
.send(Ok(Event::default()
.event("done")
.data(format!(
r#"{{"release_intent_id":"{}"}}"#,
release_intent_id
))))
.await;
Ok(())
}

View File

@@ -1,4 +1,5 @@
mod auth;
mod events;
mod pages;
mod platform;
@@ -14,10 +15,22 @@ pub fn router() -> Router<AppState> {
.merge(pages::router())
.merge(auth::router())
.merge(platform::router())
.merge(events::router())
}
/// Render an error page with the given status code, heading, and message.
fn error_page(state: &AppState, status: StatusCode, heading: &str, message: &str) -> Response {
error_page_detail(state, status, heading, message, None)
}
/// Render an error page with optional error detail (shown in a collapsible section).
fn error_page_detail(
state: &AppState,
status: StatusCode,
heading: &str,
message: &str,
detail: Option<&str>,
) -> Response {
let html = state.templates.render(
"pages/error.html.jinja",
context! {
@@ -26,6 +39,7 @@ fn error_page(state: &AppState, status: StatusCode, heading: &str, message: &str
status => status.as_u16(),
heading => heading,
message => message,
detail => detail,
},
);
match html {
@@ -33,3 +47,28 @@ fn error_page(state: &AppState, status: StatusCode, heading: &str, message: &str
Err(_) => status.into_response(),
}
}
/// Log an error and render a 500 page with the error detail.
fn internal_error(state: &AppState, context: &str, err: &dyn std::fmt::Display) -> Response {
let detail = format!("{err:#}");
tracing::error!("{context}: {detail}");
error_page_detail(
state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"An internal error occurred. Please try again.",
Some(&detail),
)
}
/// Log a warning for a failed call and return the default value.
/// Use for supplementary data where graceful degradation is acceptable.
fn warn_default<T: Default>(context: &str, result: Result<T, impl std::fmt::Display>) -> T {
match result {
Ok(v) => v,
Err(e) => {
tracing::warn!("{context}: {e:#}");
T::default()
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
use std::sync::Arc;
use crate::forest_client::GrpcForestClient;
use crate::templates::TemplateEngine;
use forage_core::auth::ForestAuth;
use forage_core::platform::ForestPlatform;
@@ -11,6 +12,7 @@ pub struct AppState {
pub forest_client: Arc<dyn ForestAuth>,
pub platform_client: Arc<dyn ForestPlatform>,
pub sessions: Arc<dyn SessionStore>,
pub grpc_client: Option<Arc<GrpcForestClient>>,
}
impl AppState {
@@ -25,6 +27,12 @@ impl AppState {
forest_client,
platform_client,
sessions,
grpc_client: None,
}
}
pub fn with_grpc_client(mut self, client: Arc<GrpcForestClient>) -> Self {
self.grpc_client = Some(client);
self
}
}

View File

@@ -40,6 +40,44 @@ fn timeago(value: &str) -> String {
}
}
/// Format a future ISO 8601 / RFC 3339 timestamp as a relative countdown.
fn timeuntil(value: &str) -> String {
let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value)
.or_else(|_| chrono::DateTime::parse_from_rfc3339(&format!("{value}Z")))
else {
return value.to_string();
};
let now = chrono::Utc::now();
let diff = dt.signed_duration_since(now);
if diff.num_seconds() <= 0 {
"now".into()
} else if diff.num_seconds() < 60 {
let s = diff.num_seconds();
format!("in {s}s")
} else if diff.num_minutes() < 60 {
let m = diff.num_minutes();
let s = diff.num_seconds() % 60;
if s > 0 {
format!("in {m}m {s}s")
} else {
format!("in {m}m")
}
} else if diff.num_hours() < 24 {
let h = diff.num_hours();
let m = diff.num_minutes() % 60;
if m > 0 {
format!("in {h}h {m}m")
} else {
format!("in {h}h")
}
} else {
let d = diff.num_days();
format!("in {d}d")
}
}
/// Format an ISO 8601 / RFC 3339 timestamp as a full human-readable datetime.
fn datetime(value: &str) -> String {
let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value)
@@ -73,7 +111,11 @@ impl TemplateEngine {
let mut env = Environment::new();
env.set_loader(minijinja::path_loader(path));
env.add_filter("timeago", |v: String| -> String { timeago(&v) });
env.add_filter("timeuntil", |v: String| -> String { timeuntil(&v) });
env.add_filter("datetime", |v: String| -> String { datetime(&v) });
env.add_filter("urlencode", |v: String| -> String {
urlencoding::encode(&v).into_owned()
});
Ok(Self { env })
}

View File

@@ -4,7 +4,9 @@ use axum::Router;
use chrono::Utc;
use forage_core::auth::*;
use forage_core::platform::{
Artifact, ArtifactContext, Destination, ForestPlatform, Organisation, OrgMember, PlatformError,
Artifact, ArtifactContext, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput,
Destination, Environment, ForestPlatform, Organisation, OrgMember, PlatformError, Policy,
ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput, UpdateTriggerInput,
};
use forage_core::session::{
CachedOrg, CachedUser, InMemorySessionStore, SessionData, SessionStore,
@@ -41,7 +43,16 @@ pub(crate) struct MockPlatformBehavior {
pub remove_member_result: Option<Result<(), PlatformError>>,
pub update_member_role_result: Option<Result<OrgMember, PlatformError>>,
pub get_artifact_by_slug_result: Option<Result<Artifact, PlatformError>>,
pub list_environments_result: Option<Result<Vec<Environment>, PlatformError>>,
pub list_destinations_result: Option<Result<Vec<Destination>, PlatformError>>,
pub list_triggers_result: Option<Result<Vec<Trigger>, PlatformError>>,
pub create_trigger_result: Option<Result<Trigger, PlatformError>>,
pub update_trigger_result: Option<Result<Trigger, PlatformError>>,
pub delete_trigger_result: Option<Result<(), PlatformError>>,
pub list_release_pipelines_result: Option<Result<Vec<ReleasePipeline>, PlatformError>>,
pub create_release_pipeline_result: Option<Result<ReleasePipeline, PlatformError>>,
pub update_release_pipeline_result: Option<Result<ReleasePipeline, PlatformError>>,
pub delete_release_pipeline_result: Option<Result<(), PlatformError>>,
}
pub(crate) fn ok_tokens() -> AuthTokens {
@@ -214,6 +225,18 @@ impl ForestAuth for MockForestClient {
}))
}
async fn get_user_by_username(
&self,
_access_token: &str,
username: &str,
) -> Result<UserProfile, AuthError> {
Ok(UserProfile {
user_id: "user-123".into(),
username: username.into(),
created_at: Some("2025-01-15T10:00:00Z".into()),
})
}
async fn remove_email(
&self,
_access_token: &str,
@@ -386,6 +409,15 @@ impl ForestPlatform for MockPlatformClient {
}))
}
async fn list_environments(
&self,
_access_token: &str,
_organisation: &str,
) -> Result<Vec<Environment>, PlatformError> {
let b = self.behavior.lock().unwrap();
b.list_environments_result.clone().unwrap_or(Ok(vec![]))
}
async fn list_destinations(
&self,
_access_token: &str,
@@ -394,6 +426,255 @@ impl ForestPlatform for MockPlatformClient {
let b = self.behavior.lock().unwrap();
b.list_destinations_result.clone().unwrap_or(Ok(vec![]))
}
async fn create_environment(
&self,
_access_token: &str,
organisation: &str,
name: &str,
description: Option<&str>,
sort_order: i32,
) -> Result<Environment, PlatformError> {
Ok(Environment {
id: format!("env-{name}"),
organisation: organisation.into(),
name: name.into(),
description: description.map(|s| s.to_string()),
sort_order,
created_at: "2026-03-08T00:00:00Z".into(),
})
}
async fn create_destination(
&self,
_access_token: &str,
_organisation: &str,
_name: &str,
_environment: &str,
_metadata: &std::collections::HashMap<String, String>,
_dest_type: Option<&forage_core::platform::DestinationType>,
) -> Result<(), PlatformError> {
Ok(())
}
async fn update_destination(
&self,
_access_token: &str,
_name: &str,
_metadata: &std::collections::HashMap<String, String>,
) -> Result<(), PlatformError> {
Ok(())
}
async fn get_destination_states(
&self,
_access_token: &str,
_organisation: &str,
_project: Option<&str>,
) -> Result<forage_core::platform::DeploymentStates, PlatformError> {
Ok(forage_core::platform::DeploymentStates {
destinations: vec![],
})
}
async fn get_release_intent_states(
&self,
_access_token: &str,
_organisation: &str,
_project: Option<&str>,
_include_completed: bool,
) -> Result<Vec<forage_core::platform::ReleaseIntentState>, PlatformError> {
Ok(vec![])
}
async fn release_artifact(
&self,
_access_token: &str,
_artifact_id: &str,
_destinations: &[String],
_environments: &[String],
_use_pipeline: bool,
) -> Result<(), PlatformError> {
Ok(())
}
async fn list_triggers(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
) -> Result<Vec<Trigger>, PlatformError> {
let b = self.behavior.lock().unwrap();
b.list_triggers_result.clone().unwrap_or(Ok(vec![]))
}
async fn create_trigger(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
input: &CreateTriggerInput,
) -> Result<Trigger, PlatformError> {
let b = self.behavior.lock().unwrap();
b.create_trigger_result.clone().unwrap_or(Ok(Trigger {
id: "trigger-1".into(),
name: input.name.clone(),
enabled: true,
branch_pattern: input.branch_pattern.clone(),
title_pattern: input.title_pattern.clone(),
author_pattern: input.author_pattern.clone(),
commit_message_pattern: input.commit_message_pattern.clone(),
source_type_pattern: input.source_type_pattern.clone(),
target_environments: input.target_environments.clone(),
target_destinations: input.target_destinations.clone(),
force_release: input.force_release,
use_pipeline: input.use_pipeline,
created_at: "2026-03-08T00:00:00Z".into(),
updated_at: "2026-03-08T00:00:00Z".into(),
}))
}
async fn update_trigger(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
name: &str,
input: &UpdateTriggerInput,
) -> Result<Trigger, PlatformError> {
let b = self.behavior.lock().unwrap();
b.update_trigger_result.clone().unwrap_or(Ok(Trigger {
id: "trigger-1".into(),
name: name.into(),
enabled: input.enabled.unwrap_or(true),
branch_pattern: input.branch_pattern.clone(),
title_pattern: input.title_pattern.clone(),
author_pattern: input.author_pattern.clone(),
commit_message_pattern: input.commit_message_pattern.clone(),
source_type_pattern: input.source_type_pattern.clone(),
target_environments: input.target_environments.clone(),
target_destinations: input.target_destinations.clone(),
force_release: input.force_release.unwrap_or(false),
use_pipeline: input.use_pipeline.unwrap_or(false),
created_at: "2026-03-08T00:00:00Z".into(),
updated_at: "2026-03-08T00:00:00Z".into(),
}))
}
async fn delete_trigger(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
_name: &str,
) -> Result<(), PlatformError> {
let b = self.behavior.lock().unwrap();
b.delete_trigger_result.clone().unwrap_or(Ok(()))
}
async fn list_policies(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
) -> Result<Vec<Policy>, PlatformError> {
Ok(vec![])
}
async fn create_policy(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
_input: &CreatePolicyInput,
) -> Result<Policy, PlatformError> {
Err(PlatformError::Other("not implemented in mock".into()))
}
async fn update_policy(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
_name: &str,
_input: &UpdatePolicyInput,
) -> Result<Policy, PlatformError> {
Err(PlatformError::Other("not implemented in mock".into()))
}
async fn delete_policy(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
_name: &str,
) -> Result<(), PlatformError> {
Ok(())
}
async fn list_release_pipelines(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
) -> Result<Vec<ReleasePipeline>, PlatformError> {
let b = self.behavior.lock().unwrap();
b.list_release_pipelines_result
.clone()
.unwrap_or(Ok(vec![]))
}
async fn create_release_pipeline(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
input: &CreateReleasePipelineInput,
) -> Result<ReleasePipeline, PlatformError> {
let b = self.behavior.lock().unwrap();
b.create_release_pipeline_result
.clone()
.unwrap_or(Ok(ReleasePipeline {
id: "pipeline-1".into(),
name: input.name.clone(),
enabled: true,
stages: input.stages.clone(),
created_at: "2026-03-08T00:00:00Z".into(),
updated_at: "2026-03-08T00:00:00Z".into(),
}))
}
async fn update_release_pipeline(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
name: &str,
input: &UpdateReleasePipelineInput,
) -> Result<ReleasePipeline, PlatformError> {
let b = self.behavior.lock().unwrap();
b.update_release_pipeline_result
.clone()
.unwrap_or(Ok(ReleasePipeline {
id: "pipeline-1".into(),
name: name.into(),
enabled: input.enabled.unwrap_or(true),
stages: input.stages.clone().unwrap_or_default(),
created_at: "2026-03-08T00:00:00Z".into(),
updated_at: "2026-03-08T00:00:00Z".into(),
}))
}
async fn delete_release_pipeline(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
_name: &str,
) -> Result<(), PlatformError> {
let b = self.behavior.lock().unwrap();
b.delete_release_pipeline_result.clone().unwrap_or(Ok(()))
}
}
pub(crate) fn make_templates() -> TemplateEngine {

View File

@@ -582,7 +582,7 @@ async fn projects_list_non_member_returns_403() {
}
#[tokio::test]
async fn projects_list_platform_unavailable_degrades_gracefully() {
async fn projects_list_platform_unavailable_returns_500() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_projects_result: Some(Err(PlatformError::Unavailable(
"connection refused".into(),
@@ -603,12 +603,13 @@ async fn projects_list_platform_unavailable_degrades_gracefully() {
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No projects yet"));
assert!(html.contains("Something went wrong"));
assert!(html.contains("connection refused"));
}
// ─── Project detail ─────────────────────────────────────────────────
@@ -634,9 +635,10 @@ async fn project_detail_returns_200_with_artifacts() {
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("my-api"));
assert!(html.contains("Deploy v1.0"));
assert!(html.contains("my-api-abc123"));
// The timeline is now rendered by a Svelte web component
assert!(html.contains("release-timeline"));
assert!(html.contains("org=\"testorg\""));
assert!(html.contains("project=\"my-api\""));
}
#[tokio::test]
@@ -664,7 +666,9 @@ async fn project_detail_empty_artifacts_shows_empty_state() {
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No releases yet"));
// Empty state is now rendered client-side by the Svelte component
assert!(html.contains("release-timeline"));
assert!(html.contains("project=\"my-api\""));
}
#[tokio::test]
@@ -698,6 +702,7 @@ async fn project_detail_shows_enriched_artifact_data() {
type_organisation: None,
type_name: None,
type_version: None,
status: None,
}],
created_at: "2026-03-07T12:00:00Z".into(),
}])),
@@ -722,10 +727,79 @@ async fn project_detail_shows_enriched_artifact_data() {
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("v2.0.0"));
assert!(html.contains("main"));
assert!(html.contains("abc1234"));
assert!(html.contains("production"));
// Enriched data is now rendered client-side by the Svelte component
assert!(html.contains("release-timeline"));
assert!(html.contains("project=\"my-api\""));
}
#[tokio::test]
async fn timeline_api_returns_json_with_artifacts() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/orgs/testorg/projects/my-api/timeline")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["timeline"].is_array());
assert!(json["lanes"].is_array());
// Should have at least one timeline item from the mock data
assert!(!json["timeline"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn org_timeline_api_returns_json() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/orgs/testorg/timeline")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["timeline"].is_array());
assert!(json["lanes"].is_array());
}
#[tokio::test]
async fn timeline_api_requires_auth() {
let (state, _sessions) = test_state();
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/orgs/testorg/projects/my-api/timeline")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// Should redirect to login (302) when not authenticated
assert_eq!(response.status(), StatusCode::SEE_OTHER);
}
// ─── Artifact detail ────────────────────────────────────────────────
@@ -787,6 +861,7 @@ async fn artifact_detail_shows_enriched_data() {
type_organisation: None,
type_name: None,
type_version: None,
status: None,
},
ArtifactDestination {
name: "staging".into(),
@@ -794,6 +869,7 @@ async fn artifact_detail_shows_enriched_data() {
type_organisation: None,
type_name: None,
type_version: None,
status: None,
},
],
created_at: "2026-03-07T12:00:00Z".into(),
@@ -1081,7 +1157,7 @@ async fn destinations_page_shows_empty_state() {
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No destinations yet"));
assert!(html.contains("No environments yet"));
}
// ─── Releases ────────────────────────────────────────────────────────
@@ -1169,5 +1245,288 @@ async fn releases_page_shows_empty_state() {
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No releases yet"));
// Empty state is now rendered client-side by the Svelte component
assert!(html.contains("release-timeline"));
assert!(html.contains("org=\"testorg\""));
}
// ─── User profile ──────────────────────────────────────────────────
#[tokio::test]
async fn user_profile_shows_username() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/users/testuser")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("testuser"));
assert!(html.contains("Member since"));
}
// ─── Triggers (auto-release) ────────────────────────────────────────
#[tokio::test]
async fn triggers_page_returns_200() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/triggers")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Triggers"));
}
#[tokio::test]
async fn triggers_page_shows_existing_triggers() {
use forage_core::platform::Trigger;
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_triggers_result: Some(Ok(vec![Trigger {
id: "t1".into(),
name: "deploy-main".into(),
enabled: true,
branch_pattern: Some("main".into()),
title_pattern: None,
author_pattern: None,
commit_message_pattern: None,
source_type_pattern: None,
target_environments: vec!["staging".into()],
target_destinations: vec![],
force_release: false,
use_pipeline: false,
created_at: "2026-03-08T00:00:00Z".into(),
updated_at: "2026-03-08T00:00:00Z".into(),
}])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/triggers")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("deploy-main"));
assert!(html.contains("staging"));
}
#[tokio::test]
async fn create_trigger_requires_admin() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/triggers")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=test-csrf&name=test-trigger"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_trigger_requires_csrf() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/triggers")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=wrong-token&name=test-trigger"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_trigger_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/triggers")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=test-csrf&name=deploy-main&branch_pattern=main&target_environments=staging")
)
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/testorg/projects/my-api/triggers"
);
}
#[tokio::test]
async fn toggle_trigger_requires_admin() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/triggers/deploy-main/toggle")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_trigger_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/triggers/deploy-main/delete")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/testorg/projects/my-api/triggers"
);
}
// ─── Deployment Policies ────────────────────────────────────────────
#[tokio::test]
async fn policies_page_returns_200() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/policies")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Deployment Policies"));
}
#[tokio::test]
async fn create_policy_requires_admin() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/policies")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=test-csrf&name=test-policy&policy_type=soak_time"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_policy_requires_csrf() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/policies")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=wrong-token&name=test-policy&policy_type=soak_time"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

1405
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

14
frontend/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "forage-frontend",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,469 @@
<svelte:options customElement="release-logs" />
<script>
let { url = "" } = $props();
// State
let destinations = $state({});
let activeTab = $state(null);
let connected = $state(false);
let done = $state(false);
let autoScroll = $state(true);
let showTimestamps = $state(true);
let expanded = $state(false);
let logContainer = $state(null);
// Derived: sorted destination names
let destNames = $derived(Object.keys(destinations).sort());
let activeLines = $derived(activeTab && destinations[activeTab] ? destinations[activeTab] : []);
function connect() {
if (!url) return;
const es = new EventSource(url);
connected = true;
es.addEventListener("log", (e) => {
try {
const data = JSON.parse(e.data);
const dest = data.destination || "unknown";
if (!destinations[dest]) {
destinations[dest] = [];
if (!activeTab) activeTab = dest;
}
destinations[dest] = [
...destinations[dest],
{
line: data.line,
timestamp: data.timestamp,
channel: data.channel || "stdout",
},
];
if (autoScroll) {
requestAnimationFrame(() => {
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight;
}
});
}
} catch (err) {
console.warn("[release-logs] bad log event:", err);
}
});
es.addEventListener("status", (e) => {
try {
const data = JSON.parse(e.data);
const dest = data.destination || "unknown";
if (!destinations[dest]) {
destinations[dest] = [];
if (!activeTab) activeTab = dest;
}
destinations[dest] = [
...destinations[dest],
{
line: `── ${data.status} ──`,
timestamp: "",
channel: "status",
},
];
} catch {}
});
es.addEventListener("done", () => {
done = true;
});
es.addEventListener("error", () => {
connected = false;
es.close();
});
return () => {
es.close();
connected = false;
};
}
$effect(() => {
if (url) {
const cleanup = connect();
return cleanup;
}
});
function handleScroll() {
if (!logContainer) return;
const atBottom =
logContainer.scrollHeight - logContainer.scrollTop - logContainer.clientHeight < 40;
autoScroll = atBottom;
}
function scrollToBottom() {
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight;
autoScroll = true;
}
}
function parseTs(ts) {
if (!ts) return null;
const n = Number(ts);
if (Number.isFinite(n) && n > 1e12) return n;
const d = new Date(ts);
return isNaN(d.getTime()) ? null : d.getTime();
}
function formatElapsed(ts, baseTs) {
const ms = parseTs(ts);
if (ms === null || baseTs === null) return "";
const diff = ms - baseTs;
if (diff < 0) return "0s";
const totalSec = Math.floor(diff / 1000);
if (totalSec < 60) return `${totalSec}s`;
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}m${String(s).padStart(2, "0")}s`;
}
// Base timestamp per destination (first log line)
let baseTimes = $derived.by(() => {
const bt = {};
for (const [dest, lines] of Object.entries(destinations)) {
for (const line of lines) {
if (line.timestamp) {
bt[dest] = parseTs(line.timestamp);
break;
}
}
}
return bt;
});
let activeBaseTime = $derived(activeTab ? baseTimes[activeTab] ?? null : null);
function formatWallClock(ts) {
const ms = parseTs(ts);
if (ms === null) return "";
const d = new Date(ms);
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
const frac = String(d.getMilliseconds()).padStart(3, "0");
return `${h}:${m}:${s}.${frac}`;
}
</script>
<div class="logs-root" class:expanded>
{#if destNames.length === 0 && !done}
<div class="logs-empty">
{#if connected}
<span class="logs-dot"></span> Waiting for logs…
{:else}
No logs available
{/if}
</div>
{:else if destNames.length === 0 && done}
<div class="logs-empty">No logs recorded for this release.</div>
{:else}
<!-- Header: tabs + controls -->
<div class="logs-header">
<div class="logs-tabs">
{#each destNames as dest}
<button
class="logs-tab"
class:active={activeTab === dest}
onclick={() => (activeTab = dest)}
>
{dest}
<span class="logs-count">{destinations[dest]?.length || 0}</span>
</button>
{/each}
</div>
<div class="logs-controls">
{#if connected && !done}
<span class="logs-live">
<span class="logs-dot"></span> Live
</span>
{/if}
<button
class="logs-ctrl-btn"
class:active={showTimestamps}
onclick={() => (showTimestamps = !showTimestamps)}
title="Toggle timestamps"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</button>
<button
class="logs-ctrl-btn"
onclick={() => (expanded = !expanded)}
title={expanded ? "Collapse" : "Expand"}
>
{#if expanded}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
{/if}
</button>
</div>
</div>
<!-- Log output -->
<div class="logs-output" bind:this={logContainer} onscroll={handleScroll}>
{#each activeLines as entry, i}
<div
class="logs-line"
class:stderr={entry.channel === "stderr"}
class:status-line={entry.channel === "status"}
>
{#if showTimestamps}
<span class="logs-ts" title={formatWallClock(entry.timestamp)}>{formatElapsed(entry.timestamp, activeBaseTime)}</span>
{/if}
<span class="logs-text">{entry.line}</span>
</div>
{/each}
</div>
{#if !autoScroll}
<button class="logs-scroll-btn" onclick={scrollToBottom}>
↓ Scroll to bottom
</button>
{/if}
{/if}
</div>
<style>
.logs-root {
position: relative;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.625;
background: #111827;
color: #d1d5db;
}
.logs-empty {
padding: 2rem;
text-align: center;
color: #6b7280;
font-family: system-ui, -apple-system, sans-serif;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.logs-header {
display: flex;
align-items: center;
background: #1f2937;
border-bottom: 1px solid #374151;
}
.logs-tabs {
display: flex;
gap: 0;
overflow-x: auto;
flex: 1;
min-width: 0;
}
.logs-tab {
padding: 0.5rem 1rem;
font-size: 0.75rem;
font-family: system-ui, -apple-system, sans-serif;
color: #9ca3af;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.375rem;
transition: color 0.15s, border-color 0.15s;
}
.logs-tab:hover {
color: #e5e7eb;
}
.logs-tab.active {
color: #f9fafb;
border-bottom-color: #3b82f6;
}
.logs-count {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
background: #374151;
color: #9ca3af;
}
.logs-controls {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.5rem;
flex-shrink: 0;
}
.logs-ctrl-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.25rem;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.logs-ctrl-btn:hover {
color: #d1d5db;
background: #374151;
}
.logs-ctrl-btn.active {
color: #93c5fd;
background: #1e3a5f;
}
.logs-live {
display: flex;
align-items: center;
gap: 0.375rem;
font-family: system-ui, -apple-system, sans-serif;
font-size: 0.6875rem;
color: #34d399;
text-transform: uppercase;
letter-spacing: 0.05em;
padding-right: 0.5rem;
}
.logs-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: #34d399;
display: inline-block;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.logs-output {
max-height: 60vh;
overflow-y: auto;
padding: 0.25rem 0;
}
.logs-root.expanded .logs-output {
max-height: 85vh;
}
.logs-output::-webkit-scrollbar {
width: 0.5rem;
}
.logs-output::-webkit-scrollbar-track {
background: #1f2937;
}
.logs-output::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 0.25rem;
}
.logs-line {
display: flex;
padding: 0 1rem 0 0;
gap: 0;
min-height: 1.5rem;
}
.logs-line:hover {
background: rgba(255, 255, 255, 0.04);
}
.logs-line.stderr {
color: #fca5a5;
background: rgba(239, 68, 68, 0.06);
}
.logs-line.stderr:hover {
background: rgba(239, 68, 68, 0.1);
}
.logs-line.status-line {
color: #93c5fd;
font-weight: 600;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
border-top: 1px solid #1e3a5f;
margin-top: 0.25rem;
}
.logs-ts {
color: #4b5563;
white-space: nowrap;
user-select: none;
flex-shrink: 0;
width: 3.5rem;
text-align: right;
padding-right: 1rem;
padding-left: 0.75rem;
border-right: 1px solid #1f2937;
margin-right: 0.75rem;
}
.logs-text {
white-space: pre-wrap;
word-break: break-all;
flex: 1;
min-width: 0;
padding-left: 1rem;
}
.logs-line .logs-ts + .logs-text {
padding-left: 0;
}
.logs-scroll-btn {
position: absolute;
bottom: 0.75rem;
left: 50%;
transform: translateX(-50%);
padding: 0.25rem 0.75rem;
font-size: 0.6875rem;
font-family: system-ui, -apple-system, sans-serif;
color: #d1d5db;
background: #374151;
border: 1px solid #4b5563;
border-radius: 9999px;
cursor: pointer;
opacity: 0.9;
transition: opacity 0.15s;
}
.logs-scroll-btn:hover {
opacity: 1;
background: #4b5563;
}
</style>

View File

@@ -0,0 +1,670 @@
<svelte:options customElement={{ tag: "release-timeline", shadow: "none" }} />
<script>
import { onMount, onDestroy, tick } from "svelte";
import { fetchTimeline, connectSSE, formatElapsed, timeAgo } from "./lib/api.js";
import { envColors, envLaneColor, envBadgeClasses, statusDotColor } from "./lib/colors.js";
import { pipelineSummary, deployStageLabel, waitStageLabel, STATUS_CONFIG } from "./lib/status.js";
// Props from attributes
export let org = "";
export let project = "";
// Reactive state
let timeline = [];
let lanes = [];
let initialLoading = true; // only true until first successful load
let error = null;
let disconnectSSE = null;
let now = Date.now();
let timerInterval = null;
// DOM refs for swim lane positioning
let timelineEl = null;
let laneBarData = {};
const BAR_WIDTH = 20;
const BAR_GAP = 4;
const DOT_SIZE = 12;
const IN_FLIGHT = new Set(["QUEUED", "RUNNING", "ASSIGNED"]);
const DEPLOYED = new Set(["SUCCEEDED"]);
// ── Data fetching ────────────────────────────────────────────────
// Debounce re-fetches: multiple SSE events within 300ms only trigger one fetch
let refetchTimer = null;
function scheduleRefetch() {
if (refetchTimer) return; // already scheduled
refetchTimer = setTimeout(() => {
refetchTimer = null;
refreshData();
}, 300);
}
async function loadData() {
try {
error = null;
const data = await fetchTimeline(org, project);
applyTimelineData(data.timeline, data.lanes);
initialLoading = false;
scheduleComputeLaneBars();
} catch (e) {
error = e.message;
initialLoading = false;
}
}
// Background refresh: merge new data without loading state
async function refreshData() {
try {
const data = await fetchTimeline(org, project);
applyTimelineData(data.timeline, data.lanes);
scheduleComputeLaneBars();
} catch (e) {
// Silently ignore refresh failures — we still have the old data
console.warn("[release-timeline] refresh failed:", e);
}
}
// Merge new timeline data, preserving object identity where possible
// to minimize DOM thrash. Uses slug as the stable key.
function applyTimelineData(newTimeline, newLanes) {
// Build a map of existing releases by slug for fast lookup
const existingBySlug = new Map();
for (const item of timeline) {
if (item.kind === "release" && item.release) {
existingBySlug.set(item.release.slug, item);
}
}
// Merge: reuse existing objects when data hasn't changed
const merged = newTimeline.map(newItem => {
if (newItem.kind !== "release" || !newItem.release) return newItem;
const existing = existingBySlug.get(newItem.release.slug);
if (!existing) return newItem;
// Shallow-compare key fields; if same, keep the old reference
const oldR = existing.release;
const newR = newItem.release;
if (oldR.dest_envs === newR.dest_envs &&
oldR.has_pipeline === newR.has_pipeline &&
pipelineStagesEqual(oldR.pipeline_stages, newR.pipeline_stages) &&
destinationsEqual(oldR.destinations, newR.destinations)) {
return existing; // same reference = no DOM update
}
return newItem;
});
timeline = merged;
lanes = newLanes;
}
function pipelineStagesEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].status !== b[i].status || a[i].started_at !== b[i].started_at || a[i].completed_at !== b[i].completed_at) return false;
}
return true;
}
function destinationsEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].status !== b[i].status || a[i].completed_at !== b[i].completed_at) return false;
}
return true;
}
// ── SSE event handling ───────────────────────────────────────────
function handleEvent(type, data) {
if (type === "destination" && data.action === "status_changed") {
handleDestinationUpdate(data);
} else if (type === "release") {
if (data.action === "created") {
scheduleRefetch();
} else if (data.action === "status_changed" || data.action === "updated") {
handleReleaseUpdate(data);
}
} else if (type === "artifact" && (data.action === "created" || data.action === "updated")) {
scheduleRefetch();
} else if (type === "pipeline") {
handlePipelineUpdate(data);
}
}
function handleDestinationUpdate(data) {
const status = data.metadata?.status;
const destName = data.metadata?.destination_name || data.resource_id;
const env = data.metadata?.environment;
if (!status || !destName) return;
let changed = false;
timeline = timeline.map(item => {
if (item.kind !== "release" || !item.release) return item;
const r = item.release;
// Check if this release has a matching destination
const destIdx = r.destinations.findIndex(d => d.name === destName);
if (destIdx === -1) return item; // no match, keep same reference
changed = true;
const newDests = r.destinations.map(d =>
d.name === destName ? { ...d, status, ...(["SUCCEEDED","FAILED","TIMED_OUT","CANCELLED"].includes(status) ? { completed_at: new Date().toISOString() } : {}) } : d
);
const newEnvStatuses = newDests.map(d => `${d.environment}:${d.status || "PENDING"}`).join(",");
const newStages = env ? r.pipeline_stages.map(s =>
s.stage_type === "deploy" && s.environment === env ? { ...s, status: status === "ASSIGNED" ? "RUNNING" : status } : s
) : r.pipeline_stages;
return {
...item,
release: { ...r, destinations: newDests, dest_envs: newEnvStatuses, pipeline_stages: newStages }
};
});
if (changed) scheduleComputeLaneBars();
}
function handleReleaseUpdate(data) {
const status = data.metadata?.status;
const env = data.metadata?.environment;
if (status && env) {
handleDestinationUpdate(data);
} else {
scheduleRefetch();
}
}
function handlePipelineUpdate(data) {
const stageStatus = data.metadata?.status;
const stageEnv = data.metadata?.environment;
const stageType = data.metadata?.stage_type;
if (!stageStatus) {
if (data.action === "created" || data.action === "updated") scheduleRefetch();
return;
}
let changed = false;
timeline = timeline.map(item => {
if (item.kind !== "release" || !item.release) return item;
const r = item.release;
let stageChanged = false;
const newStages = r.pipeline_stages.map(s => {
if (stageEnv && s.stage_type === "deploy" && s.environment === stageEnv) {
stageChanged = true;
return { ...s, status: stageStatus, ...(s.started_at ? {} : { started_at: new Date().toISOString() }) };
}
if (stageType === "wait" && s.stage_type === "wait") {
stageChanged = true;
return { ...s, status: stageStatus };
}
return s;
});
if (!stageChanged) return item; // keep same reference
changed = true;
return { ...item, release: { ...r, pipeline_stages: newStages } };
});
if (changed) scheduleComputeLaneBars();
}
// ── Swim lane bar computation ────────────────────────────────────
function parseEnvs(raw) {
if (!raw) return [];
return raw.split(",").map(s => s.trim()).filter(Boolean).map(entry => {
const colon = entry.indexOf(":");
if (colon === -1) return { env: entry, status: "SUCCEEDED" };
return { env: entry.slice(0, colon), status: entry.slice(colon + 1) };
});
}
// Debounce lane bar computation to one per frame
let laneBarRaf = null;
function scheduleComputeLaneBars() {
if (laneBarRaf) return;
laneBarRaf = requestAnimationFrame(() => {
laneBarRaf = null;
tick().then(computeLaneBars);
});
}
function computeLaneBars() {
if (!timelineEl) return;
const timelineRect = timelineEl.getBoundingClientRect();
if (timelineRect.height === 0) return;
const timelineH = timelineRect.height;
const cards = Array.from(timelineEl.querySelectorAll("[data-release]"));
const newBarData = {};
for (const lane of lanes) {
const env = lane.name;
let deployedCard = null, flightCard = null;
let deployedIdx = -1, flightIdx = -1;
for (let i = 0; i < cards.length; i++) {
const entries = parseEnvs(cards[i].dataset.envs);
for (const entry of entries) {
if (entry.env !== env) continue;
if (DEPLOYED.has(entry.status) && !deployedCard) { deployedCard = cards[i]; deployedIdx = i; }
if (IN_FLIGHT.has(entry.status) && !flightCard) { flightCard = cards[i]; flightIdx = i; }
}
}
const deployedTop = deployedCard ? deployedCard.getBoundingClientRect().top - timelineRect.top : null;
const flightTop = flightCard ? flightCard.getBoundingClientRect().top - timelineRect.top : null;
let solidH = 0;
if (deployedTop !== null && flightTop !== null) {
solidH = timelineH - Math.max(deployedTop, flightTop);
} else if (deployedTop !== null) {
solidH = timelineH - deployedTop;
}
const hasHatch = !!flightCard;
let hatchTop = 0, hatchH = 0, isForward = false;
if (flightCard) {
isForward = deployedIdx === -1 || flightIdx < deployedIdx;
const anchorY = deployedTop !== null ? deployedTop : timelineH;
const topY = Math.min(anchorY, flightTop);
const bottomY = Math.max(anchorY, flightTop);
hatchTop = topY;
hatchH = Math.max(bottomY - topY, 4);
}
const dots = [];
for (const card of cards) {
const entries = parseEnvs(card.dataset.envs);
if (!entries.find(e => e.env === env)) continue;
const avatar = card.querySelector("[data-avatar]");
const anchor = avatar || card;
const r = anchor.getBoundingClientRect();
dots.push(r.top + r.height / 2 - timelineRect.top);
}
newBarData[env] = { solidH, hasHatch, hatchTop, hatchH, isForward, dots, color: envColors(env) };
}
laneBarData = newBarData;
}
// ── Hatch pattern SVG ────────────────────────────────────────────
// Cache hatch pattern data URIs to avoid re-encoding on every render
const hatchCache = new Map();
function hatchPattern(color, bgColor) {
const key = `${color}|${bgColor}`;
let cached = hatchCache.get(key);
if (cached) return cached;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="${bgColor}"/><path d="M-2,2 l4,-4 M0,8 l8,-8 M6,10 l4,-4" stroke="${color}" stroke-width="1.5" opacity="0.6"/></svg>`;
cached = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
hatchCache.set(key, cached);
return cached;
}
// ── Lifecycle ────────────────────────────────────────────────────
onMount(() => {
loadData();
// Update "time ago" labels every 10 seconds instead of every 1 second
// — 1s resolution adds no value for "3m ago" style labels
timerInterval = setInterval(() => { now = Date.now(); }, 10000);
});
onDestroy(() => {
if (disconnectSSE) disconnectSSE();
if (timerInterval) clearInterval(timerInterval);
if (refetchTimer) clearTimeout(refetchTimer);
if (laneBarRaf) cancelAnimationFrame(laneBarRaf);
});
// Connect SSE after first data load
$: if (!initialLoading && !error && org && !disconnectSSE) {
disconnectSSE = connectSSE(org, project, handleEvent);
}
// Recompute lane bars on window resize (debounced via rAF)
function handleResize() { scheduleComputeLaneBars(); }
// ── Helpers for template ─────────────────────────────────────────
function elapsedStr(startedAt, completedAt, status) {
if (!startedAt) return "";
const start = new Date(startedAt).getTime();
if (isNaN(start)) return "";
if (completedAt && status !== "RUNNING" && status !== "QUEUED") {
const end = new Date(completedAt).getTime();
if (!isNaN(end)) return formatElapsed(Math.floor((end - start) / 1000));
}
return formatElapsed(Math.floor((now - start) / 1000));
}
// Unique key for each timeline item (used in keyed {#each})
function itemKey(item) {
if (item.kind === "release" && item.release) return `r:${item.release.slug}`;
if (item.kind === "hidden") return `h:${item.count}:${(item.releases || [])[0]?.slug || ""}`;
return `u:${Math.random()}`;
}
// Which deploy stages to show as badges on the summary line,
// filtered to match the current pipeline state.
function summaryShowsStage(summary, stageStatus) {
if (!summary) return false;
switch (summary.label) {
case "Pipeline complete": return stageStatus === "SUCCEEDED";
case "Pipeline failed": return stageStatus === "FAILED" || stageStatus === "RUNNING" || stageStatus === "ASSIGNED";
case "Deploying to": return stageStatus === "RUNNING" || stageStatus === "ASSIGNED";
case "Queued": return stageStatus === "QUEUED";
case "Waiting for time window": return stageStatus === "RUNNING" || stageStatus === "ASSIGNED";
default: return stageStatus !== "PENDING" && stageStatus !== "SUCCEEDED";
}
}
$: laneCount = lanes.length;
$: gutterWidth = laneCount * (BAR_WIDTH + BAR_GAP) + 8;
</script>
<svelte:window on:resize={handleResize} />
{#if initialLoading}
<div class="max-w-5xl mx-auto p-12 text-center text-gray-400">
<span class="w-5 h-5 inline-block border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin"></span>
<p class="mt-2 text-sm">Loading releases...</p>
</div>
{:else if error}
<div class="max-w-5xl mx-auto p-6 border border-red-200 rounded-lg text-center">
<p class="text-red-600">{error}</p>
<button class="mt-2 text-sm text-gray-500 hover:text-gray-900 underline" on:click={loadData}>Retry</button>
</div>
{:else if timeline.length === 0}
<div class="max-w-5xl mx-auto p-6 border border-gray-200 rounded-lg text-center">
<p class="text-gray-600">No releases yet.</p>
<p class="text-sm text-gray-400 mt-2">Create a release with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
</div>
{:else}
<div class="max-w-5xl mx-auto grid" style="grid-template-columns: {gutterWidth}px 1fr; grid-template-rows: 1fr auto;">
<!-- Swim lane gutter -->
<div class="flex" style="grid-row: 1;">
{#each lanes as lane (lane.name)}
{@const bar = laneBarData[lane.name]}
{@const [barColor, lightColor] = bar?.color || [lane.color, "#e5e7eb"]}
<div style="width: {BAR_WIDTH}px; margin-right: {BAR_GAP}px; position: relative;">
{#if bar}
{#if bar.hasHatch}
<div class="lane-bar lane-pulse" style="position: absolute; left: 0; width: 100%; top: {bar.hatchTop}px; height: {bar.hatchH + (bar.solidH > 0 ? BAR_WIDTH / 2 : 0)}px; background-image: {bar.isForward ? hatchPattern(barColor, lightColor) : hatchPattern('#f59e0b', '#fef3c7')}; background-size: 8px 8px; background-repeat: repeat; border-radius: 9999px; z-index: 0;"></div>
{/if}
{#if bar.solidH > 0}
<div class="lane-bar" style="position: absolute; bottom: 0; left: 0; width: 100%; height: {bar.solidH + (bar.hasHatch ? BAR_WIDTH / 2 : 0)}px; background: {barColor}; border-radius: 9999px; z-index: 1;"></div>
{/if}
{#each bar.dots as dotY, di (di)}
<div class="lane-dot" style="position: absolute; left: 50%; transform: translateX(-50%); top: {dotY - DOT_SIZE/2}px; width: {DOT_SIZE}px; height: {DOT_SIZE}px; border-radius: 50%; background: #fff; border: 2px solid {barColor}; z-index: 2;"></div>
{/each}
{/if}
</div>
{/each}
</div>
<!-- Timeline cards -->
<div bind:this={timelineEl} class="space-y-3 min-w-0" style="grid-row: 1;">
{#each timeline as item (itemKey(item))}
{#if item.kind === "release" && item.release}
{@const release = item.release}
<div data-release data-envs={release.dest_envs} class="border border-gray-200 rounded-lg overflow-hidden">
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="inline-block w-6 h-6 rounded-full bg-gray-200 shrink-0" data-avatar></span>
<a href="/orgs/{org}/projects/{release.project_name || project}/releases/{release.slug}" class="font-medium text-gray-900 hover:text-black truncate">
{release.title}
</a>
</div>
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0 flex-wrap">
{#if release.branch}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z"/></svg>
{release.branch}
</span>
{/if}
{#if release.commit_sha}
<span class="font-mono">{release.commit_sha.slice(0, 7)}</span>
{/if}
<time>{timeAgo(release.created_at)}</time>
{#if release.source_user}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<a href="/users/{release.source_user}" class="hover:underline">{release.source_user}</a>
</span>
{/if}
{#if release.project_name && release.project_name !== project}
<a href="/orgs/{org}/projects/{release.project_name}" class="hover:underline">{release.project_name}</a>
{/if}
</div>
</div>
<!-- Summary + details -->
<details class="border-t border-gray-100 group" on:toggle={scheduleComputeLaneBars}>
<summary class="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer list-none hover:bg-gray-50 flex-wrap">
{#if release.has_pipeline && !pipelineSummary(release.pipeline_stages)}
<!-- Pipeline exists but not triggered yet -->
{@const envAllDone = release.env_groups && release.env_groups.length > 0 && release.env_groups.every(g => g.status === "SUCCEEDED")}
<svg class="w-3.5 h-3.5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
{#if envAllDone}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-500 text-sm">Deployed</span>
{:else}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-blue-600 text-sm">Queued</span>
{/if}
{:else if release.has_pipeline && pipelineSummary(release.pipeline_stages)}
{@const summary = pipelineSummary(release.pipeline_stages)}
<svg class="w-3.5 h-3.5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
{#if summary.icon === "pulse"}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{:else if summary.icon === "check-circle"}
<svg class="w-4 h-4 {summary.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if summary.icon === "x-circle"}
<svg class="w-4 h-4 {summary.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if summary.icon === "clock"}
<svg class="w-4 h-4 {summary.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
{/if}
<span class="{summary.color} text-sm">{summary.label}</span>
{#each release.pipeline_stages as stage (stage.id || stage.environment || stage.stage_type)}
{#if stage.stage_type === "deploy" && summaryShowsStage(summary, stage.status)}
{@const badge = envBadgeClasses(stage.environment || "")}
{@const dot = statusDotColor(stage.status) || badge.dot}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {badge.bg}">
{stage.environment}
<span class="w-1.5 h-1.5 rounded-full {dot}"></span>
</span>
{/if}
{/each}
<span class="text-xs text-gray-400">{summary.done}/{summary.total}</span>
{:else if release.env_groups && release.env_groups.length > 0}
{@const allSucceeded = release.env_groups.every(g => g.status === "SUCCEEDED")}
{#if allSucceeded}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-500 text-sm">Deployed</span>
{:else}
{#each release.env_groups as group, gi (gi)}
{#if group.status !== "SUCCEEDED"}
{@const cfg = STATUS_CONFIG[group.status] || STATUS_CONFIG.SUCCEEDED}
{#if cfg.icon === "pulse"}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{:else if cfg.icon === "check-circle"}
<svg class="w-4 h-4 {cfg.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else}
<svg class="w-4 h-4 {cfg.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{/if}
<span class="{cfg.color} text-sm">{cfg.label}</span>
{#each group.envs as env (env)}
{@const badge = envBadgeClasses(env)}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {badge.bg}">
{env}
<span class="w-1.5 h-1.5 rounded-full {badge.dot}"></span>
</span>
{/each}
{/if}
{/each}
{/if}
{:else}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-400 text-sm">Pending</span>
{/if}
<svg class="w-3 h-3 text-gray-400 shrink-0 ml-auto transition-transform group-open:rotate-90" 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>
</summary>
<!-- Release details -->
<div class="px-4 py-3 border-t border-gray-100 space-y-3">
{#if release.description}
<p class="text-sm text-gray-700">{release.description}</p>
{/if}
<div class="flex flex-wrap gap-x-6 gap-y-2 text-xs text-gray-500">
<span class="font-mono text-gray-400">{release.slug}</span>
{#if release.version}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">{release.version}</span>
{/if}
</div>
</div>
<!-- Pipeline stages -->
{#if release.has_pipeline}
<div class="border-t border-gray-100">
{#each release.pipeline_stages as stage, i (stage.id || `${stage.stage_type}-${stage.environment}-${i}`)}
<div class="px-4 py-2.5 flex items-center gap-3 text-sm {i < release.pipeline_stages.length - 1 ? 'border-b border-gray-50' : ''} {stage.status === 'PENDING' ? 'opacity-50' : ''}">
{#if stage.status === "SUCCEEDED"}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if stage.status === "RUNNING"}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{:else if stage.status === "QUEUED"}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if stage.status === "FAILED"}
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
{/if}
{#if stage.stage_type === "deploy"}
<span class="text-sm {stage.status === 'SUCCEEDED' ? 'text-gray-700' : stage.status === 'RUNNING' ? 'text-yellow-700' : stage.status === 'FAILED' ? 'text-red-700' : 'text-gray-400'}">
{deployStageLabel(stage.status)}
</span>
{@const badge = envBadgeClasses(stage.environment || "")}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {badge.bg}">
{stage.environment}
<span class="w-1.5 h-1.5 rounded-full {badge.dot}"></span>
</span>
{:else if stage.stage_type === "wait"}
<span class="text-sm {stage.status === 'SUCCEEDED' ? 'text-gray-700' : stage.status === 'RUNNING' ? 'text-yellow-700' : 'text-gray-400'}">
{waitStageLabel(stage.status)} {stage.duration_seconds}s
</span>
{/if}
{#if stage.started_at && (stage.status === "RUNNING" || stage.status === "QUEUED" || stage.completed_at)}
<span class="text-xs text-gray-400 tabular-nums">{elapsedStr(stage.started_at, stage.completed_at, stage.status)}</span>
{/if}
<span class="ml-auto flex items-center gap-1 text-xs text-gray-400 shrink-0">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
pipeline
</span>
</div>
{/each}
</div>
{/if}
<!-- Destinations -->
{#each release.destinations as dest, i (dest.name)}
{@const destBadge = envBadgeClasses(dest.environment || "")}
<div class="px-4 py-2 flex items-center gap-3 text-sm {i < release.destinations.length - 1 ? 'border-b border-gray-50' : ''} border-t border-gray-100">
{#if dest.status === "SUCCEEDED"}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if dest.status === "RUNNING" || dest.status === "ASSIGNED"}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{:else if dest.status === "QUEUED"}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if dest.status === "FAILED"}
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{/if}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {destBadge.bg}">
{dest.environment}
<span class="w-1.5 h-1.5 rounded-full {destBadge.dot}"></span>
</span>
<span class="text-gray-400 text-xs">{dest.name}</span>
{#if dest.status === "SUCCEEDED"}
<span class="text-xs text-green-600">Deployed</span>
{:else if dest.status === "RUNNING"}
<span class="text-xs text-yellow-600">Deploying</span>
{:else if dest.status === "QUEUED"}
<span class="text-xs text-blue-600">Queued{dest.queue_position ? ` #${dest.queue_position}` : ""}</span>
{:else if dest.status === "FAILED"}
<span class="text-xs text-red-600">Failed</span>
{/if}
{#if dest.completed_at}
<time class="text-xs text-gray-400 ml-auto">{timeAgo(dest.completed_at)}</time>
{/if}
</div>
{/each}
</details>
</div>
{:else if item.kind === "hidden"}
<details class="group" on:toggle={scheduleComputeLaneBars}>
<summary class="flex items-center gap-2 py-2 px-1 text-sm text-gray-400 cursor-pointer hover:text-gray-600 list-none">
<svg class="w-3 h-3 transition-transform group-open:rotate-90" 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>
{item.count} hidden commit{item.count !== 1 ? "s" : ""}
<span class="text-gray-300">&middot;</span>
<span class="group-open:hidden">Show commit{item.count !== 1 ? "s" : ""}</span>
<span class="hidden group-open:inline">Hide commit{item.count !== 1 ? "s" : ""}</span>
</summary>
<div class="space-y-3 mt-1">
{#each item.releases || [] as release (release.slug)}
<div data-release data-envs="" class="border border-gray-200 rounded-lg overflow-hidden opacity-75">
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="inline-block w-6 h-6 rounded-full bg-gray-200 shrink-0" data-avatar></span>
<a href="/orgs/{org}/projects/{release.project_name || project}/releases/{release.slug}" class="font-medium text-gray-900 hover:text-black truncate">
{release.title}
</a>
</div>
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0">
{#if release.commit_sha}
<span class="font-mono">{release.commit_sha.slice(0, 7)}</span>
{/if}
<time>{timeAgo(release.created_at)}</time>
</div>
</div>
</div>
{/each}
</div>
</details>
{/if}
{/each}
</div>
<!-- Lane labels (row 2, column 1) -->
<div class="flex pt-1" style="grid-row: 2; grid-column: 1; height: 56px;">
{#each lanes as lane (lane.name)}
<div style="width: {BAR_WIDTH}px; margin-right: {BAR_GAP}px; display: flex; justify-content: center;">
<span style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 10px; font-weight: 500; color: {lane.color}; white-space: nowrap;">{lane.name}</span>
</div>
{/each}
</div>
</div>
{/if}
<style>
@keyframes lane-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
:global(.lane-pulse) {
animation: lane-pulse 2s ease-in-out infinite;
}
</style>

96
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,96 @@
/**
* Fetch timeline data from the JSON API.
* @param {string} org
* @param {string} project
* @returns {Promise<{timeline: Array, lanes: Array}>}
*/
export async function fetchTimeline(org, project) {
const url = project
? `/api/orgs/${org}/projects/${project}/timeline`
: `/api/orgs/${org}/timeline`;
const res = await fetch(url, {
credentials: "same-origin",
});
if (!res.ok) throw new Error(`Timeline fetch failed: ${res.status}`);
return res.json();
}
/**
* Connect to SSE endpoint for live updates.
* Returns a disconnect function.
* @param {string} org
* @param {string} project
* @param {(type: string, data: object) => void} onEvent
* @returns {() => void} disconnect
*/
export function connectSSE(org, project, onEvent) {
const url = project
? `/orgs/${org}/projects/${project}/events`
: `/orgs/${org}/events`;
let retryDelay = 1000;
let es = null;
let stopped = false;
function connect() {
if (stopped) return;
es = new EventSource(url);
es.addEventListener("open", () => {
retryDelay = 1000;
});
for (const type of ["destination", "release", "artifact", "pipeline"]) {
es.addEventListener(type, (e) => {
try {
const data = JSON.parse(e.data);
onEvent(type, data);
} catch (err) {
console.warn(`[release-timeline] bad ${type} event:`, err);
}
});
}
es.addEventListener("error", () => {
es.close();
if (!stopped) {
setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000);
}
});
}
connect();
return () => {
stopped = true;
if (es) es.close();
};
}
/**
* Format elapsed time from seconds.
*/
export function formatElapsed(seconds) {
if (seconds < 0) seconds = 0;
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m < 60) return `${m}m ${s}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
/**
* Format a relative timestamp.
*/
export function timeAgo(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = Date.now();
const diff = Math.floor((now - date.getTime()) / 1000);
if (diff < 10) return "just now";
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}

View File

@@ -0,0 +1,53 @@
/** Environment-to-color mapping (matches swim-lanes.js + platform.rs) */
const ENV_COLORS = {
prod: ["#ec4899", "#fce7f3"],
production: ["#ec4899", "#fce7f3"],
preprod: ["#f97316", "#ffedd5"],
"pre-prod": ["#f97316", "#ffedd5"],
staging: ["#eab308", "#fef9c3"],
stage: ["#eab308", "#fef9c3"],
dev: ["#8b5cf6", "#ede9fe"],
development: ["#8b5cf6", "#ede9fe"],
test: ["#06b6d4", "#cffafe"],
};
const DEFAULT_COLORS = ["#6b7280", "#e5e7eb"];
export function envColors(name) {
const lower = name.toLowerCase();
if (ENV_COLORS[lower]) return ENV_COLORS[lower];
for (const [key, colors] of Object.entries(ENV_COLORS)) {
if (lower.includes(key)) return colors;
}
return DEFAULT_COLORS;
}
export function envLaneColor(name) {
return envColors(name)[0];
}
export function envBadgeClasses(env) {
const lower = env.toLowerCase();
if (lower.includes("prod") && !lower.includes("preprod") && !lower.includes("pre-prod")) {
return { bg: "bg-pink-100 text-pink-800", dot: "bg-pink-500" };
}
if (lower.includes("preprod") || lower.includes("pre-prod")) {
return { bg: "bg-orange-100 text-orange-800", dot: "bg-orange-500" };
}
if (lower.includes("stag")) {
return { bg: "bg-yellow-100 text-yellow-800", dot: "bg-yellow-500" };
}
if (lower.includes("dev")) {
return { bg: "bg-violet-100 text-violet-800", dot: "bg-violet-500" };
}
return { bg: "bg-gray-100 text-gray-700", dot: "bg-gray-400" };
}
export function statusDotColor(status) {
switch (status) {
case "SUCCEEDED": return "bg-green-500";
case "RUNNING": return "bg-yellow-500";
case "FAILED": return "bg-red-500";
default: return null;
}
}

View File

@@ -0,0 +1,63 @@
/** Status display configuration — matches live-events.js */
export const STATUS_CONFIG = {
SUCCEEDED: { label: "Deployed to", stageLabel: "Deployed to", color: "text-green-600", icon: "check-circle", iconColor: "text-green-500" },
RUNNING: { label: "Deploying to", stageLabel: "Deploying to", color: "text-yellow-700", icon: "pulse", iconColor: "text-yellow-500" },
ASSIGNED: { label: "Deploying to", stageLabel: "Deploying to", color: "text-yellow-700", icon: "pulse", iconColor: "text-yellow-500" },
QUEUED: { label: "Queued for", stageLabel: "Queued for", color: "text-blue-600", icon: "clock", iconColor: "text-blue-400" },
FAILED: { label: "Failed on", stageLabel: "Failed on", color: "text-red-600", icon: "x-circle", iconColor: "text-red-500" },
TIMED_OUT: { label: "Timed out on", stageLabel: "Timed out on", color: "text-orange-600", icon: "clock", iconColor: "text-orange-500" },
CANCELLED: { label: "Cancelled", stageLabel: "Cancelled", color: "text-gray-500", icon: "ban", iconColor: "text-gray-400" },
};
export function pipelineSummary(stages) {
if (!stages || stages.length === 0) return null;
let allDone = true, anyFailed = false, anyRunning = false, anyWaiting = false, anyQueued = false;
let done = 0;
const total = stages.length;
for (const s of stages) {
if (s.status === "SUCCEEDED") done++;
if (s.status !== "SUCCEEDED") allDone = false;
if (s.status === "FAILED") anyFailed = true;
if (s.status === "RUNNING") anyRunning = true;
if (s.status === "QUEUED") anyQueued = true;
if (s.stage_type === "wait" && s.status === "RUNNING") anyWaiting = true;
}
if (allDone) return { label: "Pipeline complete", color: "text-gray-600", icon: "check-circle", iconColor: "text-green-500", done, total };
if (anyFailed) return { label: "Pipeline failed", color: "text-red-600", icon: "x-circle", iconColor: "text-red-500", done, total };
if (anyWaiting) return { label: "Waiting for time window", color: "text-yellow-700", icon: "clock", iconColor: "text-yellow-500", done, total };
if (anyRunning) return { label: "Deploying to", color: "text-yellow-700", icon: "pulse", iconColor: "text-yellow-500", done, total };
if (anyQueued) return { label: "Queued", color: "text-blue-600", icon: "clock", iconColor: "text-blue-400", done, total };
return { label: "Pipeline pending", color: "text-gray-400", icon: "pending", iconColor: "text-gray-300", done, total };
}
export function envGroupSummary(envGroups) {
if (!envGroups || envGroups.length === 0) return null;
return envGroups.map(g => ({
...g,
config: STATUS_CONFIG[g.status] || STATUS_CONFIG.SUCCEEDED,
}));
}
export function waitStageLabel(status) {
switch (status) {
case "SUCCEEDED": return "Waited";
case "RUNNING": return "Waiting";
case "FAILED": return "Wait failed";
case "CANCELLED": return "Wait cancelled";
default: return "Wait";
}
}
export function deployStageLabel(status) {
switch (status) {
case "SUCCEEDED": return "Deployed to";
case "RUNNING": return "Deploying to";
case "QUEUED": return "Queued for";
case "FAILED": return "Failed on";
case "TIMED_OUT": return "Timed out on";
case "CANCELLED": return "Cancelled";
default: return "Deploy to";
}
}

3
frontend/src/main.js Normal file
View File

@@ -0,0 +1,3 @@
// Register all Svelte web components
import "./ReleaseTimeline.svelte";
import "./ReleaseLogs.svelte";

27
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({
plugins: [
svelte({
compilerOptions: {
customElement: true,
},
}),
],
build: {
lib: {
entry: "src/main.js",
formats: ["iife"],
name: "ForageComponents",
fileName: () => "forage-components.js",
},
outDir: "../static/js/components",
emptyOutDir: true,
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
});

View File

@@ -0,0 +1,79 @@
syntax = "proto3";
package forest.v1;
import "forest/v1/releases.proto";
message AutoReleasePolicy {
string id = 1;
string name = 2;
bool enabled = 3;
optional string branch_pattern = 4;
optional string title_pattern = 5;
optional string author_pattern = 6;
optional string commit_message_pattern = 7;
optional string source_type_pattern = 8;
repeated string target_environments = 9;
repeated string target_destinations = 10;
bool force_release = 11;
string created_at = 12;
string updated_at = 13;
// When true, trigger the project's release pipeline instead of
// deploying directly to target destinations/environments.
bool use_pipeline = 14;
}
message CreateAutoReleasePolicyRequest {
Project project = 1;
string name = 2;
optional string branch_pattern = 3;
optional string title_pattern = 4;
optional string author_pattern = 5;
optional string commit_message_pattern = 6;
optional string source_type_pattern = 7;
repeated string target_environments = 8;
repeated string target_destinations = 9;
bool force_release = 10;
bool use_pipeline = 11;
}
message CreateAutoReleasePolicyResponse {
AutoReleasePolicy policy = 1;
}
message UpdateAutoReleasePolicyRequest {
Project project = 1;
string name = 2;
optional bool enabled = 3;
optional string branch_pattern = 4;
optional string title_pattern = 5;
optional string author_pattern = 6;
optional string commit_message_pattern = 7;
optional string source_type_pattern = 8;
repeated string target_environments = 9;
repeated string target_destinations = 10;
optional bool force_release = 11;
optional bool use_pipeline = 12;
}
message UpdateAutoReleasePolicyResponse {
AutoReleasePolicy policy = 1;
}
message DeleteAutoReleasePolicyRequest {
Project project = 1;
string name = 2;
}
message DeleteAutoReleasePolicyResponse {}
message ListAutoReleasePoliciesRequest {
Project project = 1;
}
message ListAutoReleasePoliciesResponse {
repeated AutoReleasePolicy policies = 1;
}
service AutoReleasePolicyService {
rpc CreateAutoReleasePolicy(CreateAutoReleasePolicyRequest) returns (CreateAutoReleasePolicyResponse);
rpc UpdateAutoReleasePolicy(UpdateAutoReleasePolicyRequest) returns (UpdateAutoReleasePolicyResponse);
rpc DeleteAutoReleasePolicy(DeleteAutoReleasePolicyRequest) returns (DeleteAutoReleasePolicyResponse);
rpc ListAutoReleasePolicies(ListAutoReleasePoliciesRequest) returns (ListAutoReleasePoliciesResponse);
}

View File

@@ -0,0 +1,57 @@
syntax = "proto3";
package forest.v1;
message CreateDestinationRequest {
string name = 1;
string environment = 2;
map<string, string> metadata = 3;
DestinationType type = 4;
string organisation = 5;
}
message CreateDestinationResponse {}
message UpdateDestinationRequest {
string name = 1;
map<string, string> metadata = 2;
}
message UpdateDestinationResponse {}
message DeleteDestinationRequest {
string name = 1;
}
message DeleteDestinationResponse {}
message GetDestinationsRequest {
string organisation = 1;
}
message GetDestinationsResponse {
repeated Destination destinations = 1;
}
message ListDestinationTypesRequest {}
message ListDestinationTypesResponse {
repeated DestinationType types = 1;
}
service DestinationService {
rpc CreateDestination(CreateDestinationRequest) returns (CreateDestinationResponse) {}
rpc UpdateDestination(UpdateDestinationRequest) returns (UpdateDestinationResponse) {}
rpc DeleteDestination(DeleteDestinationRequest) returns (DeleteDestinationResponse) {}
rpc GetDestinations(GetDestinationsRequest) returns (GetDestinationsResponse);
rpc ListDestinationTypes(ListDestinationTypesRequest) returns (ListDestinationTypesResponse);
}
message Destination {
string name = 1;
string environment = 2;
map<string, string> metadata = 3;
DestinationType type = 4;
string organisation = 5;
}
message DestinationType {
string organisation = 1;
string name = 2;
uint64 version = 3;
}

View File

@@ -0,0 +1,67 @@
syntax = "proto3";
package forest.v1;
message Environment {
string id = 1;
string organisation = 2;
string name = 3;
optional string description = 4;
int32 sort_order = 5;
string created_at = 6;
}
message CreateEnvironmentRequest {
string organisation = 1;
string name = 2;
optional string description = 3;
int32 sort_order = 4;
}
message CreateEnvironmentResponse {
Environment environment = 1;
}
message GetEnvironmentRequest {
oneof identifier {
string id = 1;
EnvironmentLookup lookup = 2;
}
}
message EnvironmentLookup {
string organisation = 1;
string name = 2;
}
message GetEnvironmentResponse {
Environment environment = 1;
}
message ListEnvironmentsRequest {
string organisation = 1;
}
message ListEnvironmentsResponse {
repeated Environment environments = 1;
}
message UpdateEnvironmentRequest {
string id = 1;
optional string description = 2;
optional int32 sort_order = 3;
}
message UpdateEnvironmentResponse {
Environment environment = 1;
}
message DeleteEnvironmentRequest {
string id = 1;
}
message DeleteEnvironmentResponse {}
service EnvironmentService {
rpc CreateEnvironment(CreateEnvironmentRequest) returns (CreateEnvironmentResponse);
rpc GetEnvironment(GetEnvironmentRequest) returns (GetEnvironmentResponse);
rpc ListEnvironments(ListEnvironmentsRequest) returns (ListEnvironmentsResponse);
rpc UpdateEnvironment(UpdateEnvironmentRequest) returns (UpdateEnvironmentResponse);
rpc DeleteEnvironment(DeleteEnvironmentRequest) returns (DeleteEnvironmentResponse);
}

View File

@@ -0,0 +1,100 @@
syntax = "proto3";
package forest.v1;
import "forest/v1/releases.proto";
// ── Stage type enum (useful for UI dropdowns / filtering) ────────────
enum StageType {
STAGE_TYPE_UNSPECIFIED = 0;
STAGE_TYPE_DEPLOY = 1;
STAGE_TYPE_WAIT = 2;
}
// ── Per-type config messages ─────────────────────────────────────────
message DeployStageConfig {
string environment = 1;
}
message WaitStageConfig {
int64 duration_seconds = 1;
}
// ── A single pipeline stage ──────────────────────────────────────────
message PipelineStage {
string id = 1;
repeated string depends_on = 2;
oneof config {
DeployStageConfig deploy = 10;
WaitStageConfig wait = 11;
}
}
// ── Runtime stage status (for observing pipeline progress) ───────────
enum PipelineStageStatus {
PIPELINE_STAGE_STATUS_UNSPECIFIED = 0;
PIPELINE_STAGE_STATUS_PENDING = 1;
PIPELINE_STAGE_STATUS_ACTIVE = 2;
PIPELINE_STAGE_STATUS_SUCCEEDED = 3;
PIPELINE_STAGE_STATUS_FAILED = 4;
PIPELINE_STAGE_STATUS_CANCELLED = 5;
}
// ── Pipeline resource ────────────────────────────────────────────────
message ReleasePipeline {
string id = 1;
string name = 2;
bool enabled = 3;
repeated PipelineStage stages = 4;
string created_at = 5;
string updated_at = 6;
}
// ── CRUD messages ────────────────────────────────────────────────────
message CreateReleasePipelineRequest {
Project project = 1;
string name = 2;
repeated PipelineStage stages = 3;
}
message CreateReleasePipelineResponse {
ReleasePipeline pipeline = 1;
}
message UpdateReleasePipelineRequest {
Project project = 1;
string name = 2;
optional bool enabled = 3;
// When set, replaces all stages. When absent, stages are unchanged.
repeated PipelineStage stages = 4;
bool update_stages = 5;
}
message UpdateReleasePipelineResponse {
ReleasePipeline pipeline = 1;
}
message DeleteReleasePipelineRequest {
Project project = 1;
string name = 2;
}
message DeleteReleasePipelineResponse {}
message ListReleasePipelinesRequest {
Project project = 1;
}
message ListReleasePipelinesResponse {
repeated ReleasePipeline pipelines = 1;
}
service ReleasePipelineService {
rpc CreateReleasePipeline(CreateReleasePipelineRequest) returns (CreateReleasePipelineResponse);
rpc UpdateReleasePipeline(UpdateReleasePipelineRequest) returns (UpdateReleasePipelineResponse);
rpc DeleteReleasePipeline(DeleteReleasePipelineRequest) returns (DeleteReleasePipelineResponse);
rpc ListReleasePipelines(ListReleasePipelinesRequest) returns (ListReleasePipelinesResponse);
}

View File

@@ -31,6 +31,10 @@ message ReleaseRequest {
string artifact_id = 1;
repeated string destinations = 2;
repeated string environments = 3;
bool force = 4;
// When true, use the project's release pipeline (DAG) instead of
// deploying directly to the specified destinations/environments.
bool use_pipeline = 5;
}
message ReleaseResponse {
// List of release intents created (one per destination)
@@ -88,6 +92,54 @@ message GetProjectsResponse {
message GetReleasesByActorRequest {
string actor_id = 1; // user_id or app_id
string actor_type = 2; // "user" or "app"
int32 page_size = 3;
string page_token = 4;
}
message GetReleasesByActorResponse {
repeated ReleaseIntentSummary releases = 1;
string next_page_token = 2;
}
message ReleaseIntentSummary {
string release_intent_id = 1;
string artifact_id = 2;
Project project = 3;
repeated ReleaseDestinationStatus destinations = 4;
string created_at = 5;
}
message ReleaseDestinationStatus {
string destination = 1;
string environment = 2;
string status = 3;
}
message GetDestinationStatesRequest {
string organisation = 1;
optional string project = 2;
}
message GetDestinationStatesResponse {
repeated DestinationState destinations = 1;
}
message DestinationState {
string destination_id = 1;
string destination_name = 2;
string environment = 3;
optional string release_id = 4;
optional string artifact_id = 5;
optional string status = 6;
optional string error_message = 7;
optional string queued_at = 8;
optional string completed_at = 9;
optional int32 queue_position = 10;
}
service ReleaseService {
rpc AnnotateRelease(AnnotateReleaseRequest) returns (AnnotateReleaseResponse);
rpc Release(ReleaseRequest) returns (ReleaseResponse);
@@ -95,8 +147,10 @@ service ReleaseService {
rpc GetArtifactBySlug(GetArtifactBySlugRequest) returns (GetArtifactBySlugResponse);
rpc GetArtifactsByProject(GetArtifactsByProjectRequest) returns (GetArtifactsByProjectResponse);
rpc GetReleasesByActor(GetReleasesByActorRequest) returns (GetReleasesByActorResponse);
rpc GetOrganisations(GetOrganisationsRequest) returns (GetOrganisationsResponse);
rpc GetProjects(GetProjectsRequest) returns (GetProjectsResponse);
rpc GetDestinationStates(GetDestinationStatesRequest) returns (GetDestinationStatesResponse);
}
message Source {
@@ -131,6 +185,7 @@ message ArtifactDestination {
string type_organisation = 3;
string type_name = 4;
uint64 type_version = 5;
string status = 6;
}
message Project {

View File

@@ -18,6 +18,9 @@ service UsersService {
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
// Stats
rpc GetUserStats(GetUserStatsRequest) returns (GetUserStatsResponse);
// Password management
rpc ChangePassword(ChangePasswordRequest) returns (ChangePasswordResponse);
@@ -280,6 +283,28 @@ message DeletePersonalAccessTokenRequest {
message DeletePersonalAccessTokenResponse {}
// ─── Stats ──────────────────────────────────────────────────────────
message GetUserStatsRequest {
oneof identifier {
string user_id = 1;
string username = 2;
}
}
message GetUserStatsResponse {
UserStats stats = 1;
}
message UserStats {
int64 total_releases = 1;
int64 successful_releases = 2;
int64 failed_releases = 3;
int64 in_progress_releases = 4;
int64 total_annotations = 5;
int64 total_uploads = 6;
}
// ─── MFA ─────────────────────────────────────────────────────────────
enum MfaType {

View File

@@ -1,11 +1,15 @@
[tools]
rust = "latest"
[env]
_.file = ".env"
# ─── Core Development ──────────────────────────────────────────────
[tasks.develop]
alias = ["d", "dev"]
description = "Start the forage development server"
depends = ["tailwind:build"]
run = "cargo run -p forage-server"
[tasks.build]
@@ -97,6 +101,8 @@ run = "cargo sqlx prepare --workspace"
[tasks."tailwind:build"]
description = "Build tailwind CSS"
sources = ["templates/**/*.jinja", "static/css/input.css", "static/js/**/*.js"]
outputs = ["static/css/style.css"]
run = "npx @tailwindcss/cli -i static/css/input.css -o static/css/style.css --minify"
[tasks."tailwind:watch"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1 +1,70 @@
@import "tailwindcss";
/* ── Dark mode (system preference) ──────────────────────────────────────── */
/* Remap Tailwind's color variables so all existing utilities adapt automatically. */
@media (prefers-color-scheme: dark) {
:root, :host {
/* Neutrals — invert the gray scale */
--color-white: oklch(14.5% 0.015 260);
--color-black: oklch(98% 0.002 248);
--color-gray-50: oklch(17.5% 0.02 260);
--color-gray-100: oklch(21% 0.024 265);
--color-gray-200: oklch(27.8% 0.025 257);
--color-gray-300: oklch(37.3% 0.025 260);
--color-gray-400: oklch(55.1% 0.02 264);
--color-gray-500: oklch(60% 0.02 264);
--color-gray-600: oklch(70.7% 0.017 261);
--color-gray-700: oklch(80% 0.012 258);
--color-gray-800: oklch(87.2% 0.008 258);
--color-gray-900: oklch(93% 0.005 265);
--color-gray-950: oklch(96.7% 0.003 265);
/* Green — darken light tints, lighten dark shades */
--color-green-50: oklch(20% 0.04 155);
--color-green-100: oklch(25% 0.06 155);
--color-green-200: oklch(30% 0.08 155);
--color-green-300: oklch(42% 0.12 154);
--color-green-700: oklch(75% 0.15 150);
--color-green-800: oklch(80% 0.12 150);
/* Red */
--color-red-50: oklch(22% 0.04 17);
--color-red-200: oklch(32% 0.06 18);
--color-red-600: oklch(65% 0.2 27);
--color-red-700: oklch(72% 0.18 27);
--color-red-800: oklch(77% 0.15 27);
/* Blue */
--color-blue-100: oklch(22% 0.04 255);
--color-blue-600: oklch(62% 0.2 263);
--color-blue-700: oklch(72% 0.17 264);
--color-blue-800: oklch(77% 0.15 265);
/* Orange */
--color-orange-100: oklch(25% 0.05 75);
--color-orange-800: oklch(78% 0.13 37);
/* Yellow */
--color-yellow-100: oklch(25% 0.06 103);
--color-yellow-700: oklch(72% 0.12 66);
--color-yellow-800: oklch(77% 0.1 62);
/* Violet */
--color-violet-100: oklch(22% 0.04 295);
--color-violet-200: oklch(28% 0.06 294);
--color-violet-400: oklch(45% 0.14 293);
--color-violet-600: oklch(60% 0.2 293);
--color-violet-800: oklch(75% 0.18 293);
/* Purple */
--color-purple-100: oklch(22% 0.04 307);
--color-purple-800: oklch(75% 0.17 304);
/* Pink */
--color-pink-100: oklch(22% 0.04 342);
--color-pink-800: oklch(75% 0.15 4);
/* Amber */
--color-amber-400: oklch(80% 0.17 84);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,38 @@
/**
* Persists the open/closed state of <details> elements inside [data-release]
* cards across page reloads using sessionStorage.
*
* Key format: `details:<page-path>:<release-slug>`
*/
(function () {
const prefix = "details:" + location.pathname + ":";
// Restore open state on load
document.querySelectorAll("[data-release][data-release-slug]").forEach((card) => {
const slug = card.dataset.releaseSlug;
const details = card.querySelector("details");
if (!details || !slug) return;
if (sessionStorage.getItem(prefix + slug) === "1") {
details.open = true;
}
});
// Listen for toggle events (works for both open and close)
document.addEventListener("toggle", (e) => {
const details = e.target;
if (details.tagName !== "DETAILS") return;
const card = details.closest("[data-release][data-release-slug]");
if (!card) return;
const slug = card.dataset.releaseSlug;
if (!slug) return;
if (details.open) {
sessionStorage.setItem(prefix + slug, "1");
} else {
sessionStorage.removeItem(prefix + slug);
}
}, true);
})();

701
static/js/live-events.js Normal file
View File

@@ -0,0 +1,701 @@
/**
* Live event updates via SSE.
*
* Connects to the project events endpoint and updates the deployment UI
* in real-time when destination statuses change.
*
* Usage: <script src="/static/js/live-events.js"
* data-org="rawpotion" data-project="my-app"></script>
*/
(function () {
const script = document.currentScript;
const org = script?.dataset.org;
const project = script?.dataset.project;
if (!org || !project) return;
const url = `/orgs/${org}/projects/${project}/events`;
let lastSequence = 0;
let retryDelay = 1000;
function connect() {
const es = new EventSource(url);
es.addEventListener("open", () => {
retryDelay = 1000;
});
// destination status_changed events update inline badges
es.addEventListener("destination", (e) => {
try {
const data = JSON.parse(e.data);
lastSequence = Math.max(lastSequence, data.sequence || 0);
handleDestinationEvent(data);
} catch (err) {
console.warn("[live-events] bad destination event:", err);
}
});
// release events
es.addEventListener("release", (e) => {
try {
const data = JSON.parse(e.data);
lastSequence = Math.max(lastSequence, data.sequence || 0);
if (data.action === "created") {
window.location.reload();
} else if (
data.action === "status_changed" ||
data.action === "updated"
) {
handleReleaseEvent(data);
}
} catch (err) {
console.warn("[live-events] bad release event:", err);
}
});
// artifact events -> reload to show new artifacts
es.addEventListener("artifact", (e) => {
try {
const data = JSON.parse(e.data);
if (data.action === "created" || data.action === "updated") {
window.location.reload();
}
} catch (err) {
console.warn("[live-events] bad artifact event:", err);
}
});
// pipeline events (pipeline run progress)
es.addEventListener("pipeline", (e) => {
try {
const data = JSON.parse(e.data);
lastSequence = Math.max(lastSequence, data.sequence || 0);
handlePipelineEvent(data);
} catch (err) {
console.warn("[live-events] bad pipeline event:", err);
}
});
es.addEventListener("error", () => {
es.close();
// Reconnect with exponential backoff
setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000);
});
}
// ── Status update helpers ──────────────────────────────────────────
const STATUS_CONFIG = {
SUCCEEDED: {
icon: "check-circle",
iconColor: "text-green-500",
label: "Deployed",
labelColor: "text-green-600",
summaryIcon: "check-circle",
summaryColor: "text-green-500",
summaryLabel: "Deployed to",
summaryLabelColor: "text-gray-600",
},
RUNNING: {
icon: "pulse",
iconColor: "text-yellow-500",
label: "Deploying",
labelColor: "text-yellow-600",
summaryIcon: "pulse",
summaryColor: "text-yellow-500",
summaryLabel: "Deploying to",
summaryLabelColor: "text-yellow-700",
},
ASSIGNED: {
icon: "pulse",
iconColor: "text-yellow-500",
label: "Assigned",
labelColor: "text-yellow-600",
summaryIcon: "pulse",
summaryColor: "text-yellow-500",
summaryLabel: "Deploying to",
summaryLabelColor: "text-yellow-700",
},
QUEUED: {
icon: "clock",
iconColor: "text-blue-400",
label: "Queued",
labelColor: "text-blue-600",
summaryIcon: "clock",
summaryColor: "text-blue-400",
summaryLabel: "Queued for",
summaryLabelColor: "text-blue-600",
},
FAILED: {
icon: "x-circle",
iconColor: "text-red-500",
label: "Failed",
labelColor: "text-red-600",
summaryIcon: "x-circle",
summaryColor: "text-red-500",
summaryLabel: "Failed on",
summaryLabelColor: "text-red-600",
},
TIMED_OUT: {
icon: "clock",
iconColor: "text-orange-500",
label: "Timed out",
labelColor: "text-orange-600",
summaryIcon: "clock",
summaryColor: "text-orange-500",
summaryLabel: "Timed out on",
summaryLabelColor: "text-orange-600",
},
CANCELLED: {
icon: "ban",
iconColor: "text-gray-400",
label: "Cancelled",
labelColor: "text-gray-500",
summaryIcon: "ban",
summaryColor: "text-gray-400",
summaryLabel: "Cancelled",
summaryLabelColor: "text-gray-500",
},
};
function makeStatusIcon(type, colorClass) {
if (type === "pulse") {
const span = document.createElement("span");
span.className = "w-4 h-4 shrink-0 flex items-center justify-center";
span.innerHTML =
'<span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span>';
return span;
}
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", `w-4 h-4 ${colorClass} shrink-0`);
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("viewBox", "0 0 24 24");
const path = document.createElementNS(
"http://www.w3.org/2000/svg",
"path"
);
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-width", "2");
const paths = {
"check-circle": "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
"x-circle":
"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z",
clock: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z",
ban: "M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636",
};
path.setAttribute("d", paths[type] || paths["check-circle"]);
svg.appendChild(path);
return svg;
}
function handleDestinationEvent(data) {
if (data.action !== "status_changed") return;
const status = data.metadata?.status;
const destName = data.metadata?.destination_name || data.resource_id;
const env = data.metadata?.environment;
if (!status || !destName) return;
const config = STATUS_CONFIG[status];
if (!config) return;
// Find all destination rows that match
document
.querySelectorAll("[data-release] details .px-4.py-2")
.forEach((row) => {
const nameSpan = row.querySelector(".text-gray-400.text-xs");
if (!nameSpan || nameSpan.textContent.trim() !== destName) return;
// Update the status icon (first child element)
const oldIcon = row.firstElementChild;
if (oldIcon) {
const newIcon = makeStatusIcon(config.icon, config.iconColor);
row.replaceChild(newIcon, oldIcon);
}
// Update the status label text
const labels = row.querySelectorAll("span[class*='text-xs text-']");
labels.forEach((label) => {
const text = label.textContent.trim();
if (
[
"Deployed",
"Deploying",
"Assigned",
"Queued",
"Failed",
"Timed out",
"Cancelled",
].some((s) => text.startsWith(s))
) {
label.textContent = config.label;
// Reset classes
label.className = `text-xs ${config.labelColor}`;
}
});
});
// Update pipeline stage rows that match this environment
if (env) {
updatePipelineStages(env, status, config);
}
// Also update the summary line for the parent release card
updateReleaseSummary(data);
}
function updatePipelineStages(env, status, config) {
document
.querySelectorAll(
`[data-pipeline-stage][data-stage-type="deploy"][data-stage-env="${env}"]`
)
.forEach((row) => {
// Update data attributes
row.dataset.stageStatus = status;
// Set started_at if transitioning to an active state and not already set
if (
(status === "RUNNING" || status === "QUEUED") &&
!row.dataset.startedAt
) {
row.dataset.startedAt = new Date().toISOString();
}
// Set completed_at when reaching a terminal state
if (
["SUCCEEDED", "FAILED", "TIMED_OUT", "CANCELLED"].includes(status) &&
!row.dataset.completedAt
) {
row.dataset.completedAt = new Date().toISOString();
}
// Ensure elapsed span exists for active stages
if (
(status === "RUNNING" || status === "QUEUED") &&
!row.querySelector("[data-elapsed]")
) {
const pipelineLabel = row.querySelector("span.ml-auto");
if (pipelineLabel) {
const el = document.createElement("span");
el.className = "text-xs text-gray-400 tabular-nums";
el.dataset.elapsed = "";
pipelineLabel.before(el);
}
}
// Toggle opacity for pending vs active
if (status === "PENDING") {
row.classList.add("opacity-50");
} else {
row.classList.remove("opacity-50");
}
// Replace status icon (first child element)
const oldIcon = row.firstElementChild;
if (oldIcon) {
const newIcon = makeStatusIcon(config.icon, config.iconColor);
row.replaceChild(newIcon, oldIcon);
}
// Update the status text span (e.g. "Deploying to" -> "Deployed to")
const textSpan = row.querySelector("span.text-sm");
if (textSpan) {
const labels = {
SUCCEEDED: "Deployed to",
RUNNING: "Deploying to",
QUEUED: "Queued for",
FAILED: "Failed on",
TIMED_OUT: "Timed out on",
CANCELLED: "Cancelled",
};
if (labels[status]) textSpan.textContent = labels[status];
// Update text color
const colors = {
SUCCEEDED: "text-gray-700",
RUNNING: "text-yellow-700",
QUEUED: "text-blue-600",
FAILED: "text-red-700",
TIMED_OUT: "text-orange-600",
CANCELLED: "text-gray-500",
};
textSpan.className = `text-sm ${colors[status] || "text-gray-600"}`;
}
// Update the env badge dot color
const badge = row.querySelector(
"span.inline-flex span.rounded-full:last-child"
);
if (badge) {
const dotColors = {
SUCCEEDED: "bg-green-500",
RUNNING: "bg-yellow-500",
FAILED: "bg-red-500",
};
if (dotColors[status]) {
badge.className = `w-1.5 h-1.5 rounded-full ${dotColors[status]}`;
}
}
});
}
function updateReleaseSummary(_data) {
// Re-compute summaries by scanning pipeline stage rows or destination rows.
document.querySelectorAll("[data-release]").forEach((card) => {
const summary = card.querySelector("details > summary");
if (!summary) return;
const pipelineStages = card.querySelectorAll("[data-pipeline-stage]");
const hasPipeline = pipelineStages.length > 0;
if (hasPipeline) {
updatePipelineSummary(summary, pipelineStages);
} else {
updateDestinationSummary(summary, card);
}
});
}
function updatePipelineSummary(summary, stages) {
let allDone = true;
let anyFailed = false;
let anyRunning = false;
let anyWaiting = false;
let done = 0;
const total = stages.length;
const envBadges = [];
stages.forEach((row) => {
const status = row.dataset.stageStatus || "PENDING";
const stageType = row.dataset.stageType;
const env = row.dataset.stageEnv;
if (status === "SUCCEEDED") done++;
if (status !== "SUCCEEDED") allDone = false;
if (status === "FAILED") anyFailed = true;
if (status === "RUNNING") anyRunning = true;
if (stageType === "wait" && status === "RUNNING") anyWaiting = true;
// Collect env badges for non-PENDING deploy stages
if (stageType === "deploy" && status !== "PENDING" && env) {
envBadges.push({ env, status });
}
});
const chevron = summary.querySelector("svg:last-child");
summary.innerHTML = "";
// Pipeline gear icon
const gear = document.createElementNS("http://www.w3.org/2000/svg", "svg");
gear.setAttribute("class", "w-3.5 h-3.5 text-purple-400 shrink-0");
gear.setAttribute("fill", "none");
gear.setAttribute("stroke", "currentColor");
gear.setAttribute("viewBox", "0 0 24 24");
gear.innerHTML =
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>';
summary.appendChild(gear);
// Status icon + label
let statusIcon, statusLabel, statusLabelColor;
if (allDone) {
statusIcon = makeStatusIcon("check-circle", "text-green-500");
statusLabel = "Pipeline complete";
statusLabelColor = "text-gray-600";
} else if (anyFailed) {
statusIcon = makeStatusIcon("x-circle", "text-red-500");
statusLabel = "Pipeline failed";
statusLabelColor = "text-red-600";
} else if (anyWaiting) {
statusIcon = makeStatusIcon("clock", "text-yellow-500");
statusLabel = "Waiting for time window";
statusLabelColor = "text-yellow-700";
} else if (anyRunning) {
statusIcon = makeStatusIcon("pulse", "text-yellow-500");
statusLabel = "Deploying to";
statusLabelColor = "text-yellow-700";
} else {
statusIcon = makeStatusIcon("clock", "text-gray-300");
statusLabel = "Pipeline pending";
statusLabelColor = "text-gray-400";
}
summary.appendChild(statusIcon);
const labelSpan = document.createElement("span");
labelSpan.className = `${statusLabelColor} text-sm`;
labelSpan.textContent = statusLabel;
summary.appendChild(labelSpan);
// Environment badges
for (const { env, status } of envBadges) {
summary.appendChild(makeEnvBadge(env, status));
}
// Progress counter
const progress = document.createElement("span");
progress.className = "text-xs text-gray-400";
progress.textContent = `${done}/${total}`;
summary.appendChild(progress);
if (chevron) summary.appendChild(chevron);
}
function updateDestinationSummary(summary, card) {
// Collect current statuses from destination rows
const rows = card.querySelectorAll("details .px-4.py-2");
const envStatuses = new Map();
rows.forEach((row) => {
const envBadge = row.querySelector("[class*='rounded-full']");
const envName =
envBadge?.closest("span[class*='px-2']")?.textContent?.trim() || "";
const labels = row.querySelectorAll("span[class*='text-xs text-']");
let status = "";
labels.forEach((l) => {
const t = l.textContent.trim();
if (t === "Deployed") status = "SUCCEEDED";
else if (t === "Deploying" || t === "Assigned") status = "RUNNING";
else if (t.startsWith("Queued")) status = "QUEUED";
else if (t === "Failed") status = "FAILED";
else if (t === "Timed out") status = "TIMED_OUT";
else if (t === "Cancelled") status = "CANCELLED";
});
if (envName && status) envStatuses.set(envName, status);
});
if (envStatuses.size === 0) return;
const groups = new Map();
for (const [env, st] of envStatuses) {
if (!groups.has(st)) groups.set(st, []);
groups.get(st).push(env);
}
const chevron = summary.querySelector("svg:last-child");
summary.innerHTML = "";
for (const [status, envs] of groups) {
const cfg = STATUS_CONFIG[status];
if (!cfg) continue;
summary.appendChild(makeStatusIcon(cfg.summaryIcon, cfg.summaryColor));
const label = document.createElement("span");
label.className = `${cfg.summaryLabelColor} text-sm`;
label.textContent = cfg.summaryLabel;
summary.appendChild(label);
for (const env of envs) {
summary.appendChild(makeEnvBadge(env, status));
}
}
if (chevron) summary.appendChild(chevron);
}
function makeEnvBadge(env, status) {
const badge = document.createElement("span");
let bgClass = "bg-gray-100 text-gray-700";
let dotClass = "bg-gray-400";
if (env.includes("prod") && !env.includes("preprod")) {
bgClass = "bg-pink-100 text-pink-800";
dotClass = "bg-pink-500";
} else if (env.includes("preprod") || env.includes("pre-prod")) {
bgClass = "bg-orange-100 text-orange-800";
dotClass = "bg-orange-500";
} else if (env.includes("stag")) {
bgClass = "bg-yellow-100 text-yellow-800";
dotClass = "bg-yellow-500";
} else if (env.includes("dev")) {
bgClass = "bg-violet-100 text-violet-800";
dotClass = "bg-violet-500";
}
// Override dot color based on stage status
const statusDots = {
SUCCEEDED: "bg-green-500",
RUNNING: "bg-yellow-500",
FAILED: "bg-red-500",
};
if (statusDots[status]) dotClass = statusDots[status];
badge.className = `inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${bgClass}`;
badge.innerHTML = `${env} <span class="w-1.5 h-1.5 rounded-full ${dotClass}"></span>`;
return badge;
}
// ── Release event handler ─────────────────────────────────────────
function handleReleaseEvent(data) {
// Release status_changed or updated: metadata may carry per-destination
// updates, or a high-level status change. Treat it as a destination update
// when we have environment + status metadata; otherwise reload for safety.
const status = data.metadata?.status;
const env = data.metadata?.environment;
if (status && env) {
// We have enough info to do an inline update
const config = STATUS_CONFIG[status];
if (config) {
updatePipelineStages(env, status, config);
updateReleaseSummary(data);
}
} else {
// Generic release change — reload to pick up new state
window.location.reload();
}
}
// ── Pipeline event handler ──────────────────────────────────────────
function handlePipelineEvent(data) {
// Pipeline events carry stage-level status updates in metadata:
// stage_id, stage_type, environment, status, started_at, completed_at, error_message
const stageStatus = data.metadata?.status;
const stageEnv = data.metadata?.environment;
const stageType = data.metadata?.stage_type;
const stageId = data.metadata?.stage_id;
if (!stageStatus) {
// Can't do inline update without status — reload
if (data.action === "created" || data.action === "updated") {
window.location.reload();
}
return;
}
const config = STATUS_CONFIG[stageStatus];
// Update pipeline stage rows by environment (deploy stages)
if (stageEnv && config) {
updatePipelineStages(stageEnv, stageStatus, config);
}
// Also update by stage_id for wait stages or when env isn't enough
if (stageId) {
document
.querySelectorAll(`[data-pipeline-stage]`)
.forEach((row) => {
// Match on the stage id attribute if we had one, but we use
// stage_type + env. For wait stages, update all wait stages
// in the same card context.
if (stageType === "wait" && row.dataset.stageType === "wait") {
row.dataset.stageStatus = stageStatus;
if (stageStatus === "RUNNING") {
row.classList.remove("opacity-50");
if (!row.dataset.startedAt) {
row.dataset.startedAt =
data.metadata?.started_at || new Date().toISOString();
}
} else if (stageStatus === "SUCCEEDED") {
row.classList.remove("opacity-50");
if (!row.dataset.completedAt) {
row.dataset.completedAt =
data.metadata?.completed_at || new Date().toISOString();
}
}
// Update icon
const iconCfg = STATUS_CONFIG[stageStatus];
if (iconCfg) {
const oldIcon = row.firstElementChild;
if (oldIcon) {
const newIcon = makeStatusIcon(
iconCfg.icon,
iconCfg.iconColor
);
row.replaceChild(newIcon, oldIcon);
}
}
// Update text ("Waiting" -> "Waited")
const textSpan = row.querySelector("span.text-sm");
if (textSpan) {
const dur = textSpan.textContent.match(/\d+s/)?.[0] || "";
if (stageStatus === "SUCCEEDED") {
textSpan.textContent = `Waited ${dur}`;
textSpan.className = "text-sm text-gray-700";
} else if (stageStatus === "RUNNING") {
textSpan.textContent = `Waiting ${dur}`;
textSpan.className = "text-sm text-yellow-700";
} else if (stageStatus === "FAILED") {
textSpan.textContent = `Wait failed ${dur}`;
textSpan.className = "text-sm text-red-700";
} else if (stageStatus === "CANCELLED") {
textSpan.textContent = `Wait cancelled ${dur}`;
textSpan.className = "text-sm text-gray-500";
}
}
// Remove wait_until span on completion
if (["SUCCEEDED", "FAILED", "CANCELLED"].includes(stageStatus)) {
const waitUntil = row.querySelector("[data-wait-until]");
if (waitUntil) waitUntil.remove();
}
// Ensure elapsed span exists
if (
(stageStatus === "RUNNING" || stageStatus === "QUEUED") &&
!row.querySelector("[data-elapsed]")
) {
const pipelineLabel = row.querySelector("span.ml-auto");
if (pipelineLabel) {
const el = document.createElement("span");
el.className = "text-xs text-gray-400 tabular-nums";
el.dataset.elapsed = "";
pipelineLabel.before(el);
}
}
}
});
}
// Re-compute summary for affected cards
updateReleaseSummary(data);
}
// ── Elapsed time tickers ──────────────────────────────────────────
function formatElapsed(seconds) {
if (seconds < 0) seconds = 0;
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m < 60) return `${m}m ${s}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function updateElapsedTimers() {
document.querySelectorAll("[data-pipeline-stage]").forEach((row) => {
const elapsed = row.querySelector("[data-elapsed]");
if (!elapsed) return;
const startedAt = row.dataset.startedAt;
if (!startedAt) return;
const start = new Date(startedAt).getTime();
if (isNaN(start)) return;
const completedAt = row.dataset.completedAt;
const status = row.dataset.stageStatus;
if (completedAt && status !== "RUNNING" && status !== "QUEUED") {
// Completed stage — show fixed duration
const end = new Date(completedAt).getTime();
if (!isNaN(end)) {
elapsed.textContent = formatElapsed(Math.floor((end - start) / 1000));
}
} else {
// Active stage — live counter
const now = Date.now();
elapsed.textContent = formatElapsed(Math.floor((now - start) / 1000));
}
});
}
// Run immediately, then tick every second
updateElapsedTimers();
setInterval(updateElapsedTimers, 1000);
// Connect on page load
connect();
})();

View File

@@ -0,0 +1,629 @@
/**
* <pipeline-builder> web component
*
* Visual DAG builder for release pipeline stages.
* Syncs to a hidden textarea (data-target) as JSON.
*
* Stage format (matches Rust serde of PipelineStage):
* { "id": "stage-name", "depends_on": ["other"], "config": {"Deploy": {"environment": "prod"}} }
*
* Usage:
* <pipeline-builder data-target="pipeline-stages"></pipeline-builder>
* <textarea id="pipeline-stages" name="stages_json" hidden></textarea>
*/
class PipelineBuilder extends HTMLElement {
connectedCallback() {
this.stages = [];
this._targetId = this.dataset.target;
this._readonly = this.dataset.readonly === "true";
this._mode = "builder"; // "builder" | "json"
// Load initial value from target textarea
const target = this._target();
if (target && target.value.trim()) {
try {
const parsed = JSON.parse(target.value.trim());
this.stages = this._parseStages(parsed);
} catch (e) {
this._rawJson = target.value.trim();
}
}
this._render();
}
_target() {
return this._targetId ? document.getElementById(this._targetId) : null;
}
// Extract the stage type string from a config object
_stageType(config) {
if (!config) return "deploy";
if (config.Deploy !== undefined) return "deploy";
if (config.Wait !== undefined) return "wait";
return "deploy";
}
// Extract display info from config
_configLabel(config) {
if (!config) return "";
if (config.Deploy) return config.Deploy.environment || "";
if (config.Wait) return config.Wait.duration_seconds ? `${config.Wait.duration_seconds}s` : "";
return "";
}
_normalizeStage(s) {
// Handle the new typed format: {id, depends_on, config: {Deploy: {environment}}}
if (s.id !== undefined) {
return {
id: s.id || "",
depends_on: Array.isArray(s.depends_on) ? s.depends_on : [],
config: s.config || { Deploy: { environment: "" } },
};
}
// Legacy format: {name, type, depends_on}
const type = s.type || "deploy";
const config = type === "wait"
? { Wait: { duration_seconds: s.duration_seconds || 0 } }
: { Deploy: { environment: s.environment || "" } };
return {
id: s.name || "",
depends_on: Array.isArray(s.depends_on) ? s.depends_on : [],
config,
};
}
_parseStages(parsed) {
if (Array.isArray(parsed)) {
return parsed.map((s) => this._normalizeStage(s));
}
if (parsed.stages && Array.isArray(parsed.stages)) {
return parsed.stages.map((s) => this._normalizeStage(s));
}
// Map format: { "id": { depends_on, config } }
if (typeof parsed === "object" && parsed !== null) {
return Object.entries(parsed).map(([id, val]) =>
this._normalizeStage({ id, ...val })
);
}
return [];
}
_sync() {
const target = this._target();
if (!target) return;
if (this.stages.length === 0) {
target.value = "";
return;
}
// Filter out stages with no id
const valid = this.stages.filter((s) => s.id.trim());
target.value = JSON.stringify(valid, null, 2);
}
_validate() {
const ids = this.stages.map((s) => s.id).filter(Boolean);
const idSet = new Set(ids);
const errors = [];
if (ids.length !== idSet.size) {
errors.push("Duplicate stage IDs detected");
}
for (const s of this.stages) {
for (const dep of s.depends_on) {
if (!idSet.has(dep)) {
errors.push(`"${s.id}" depends on unknown stage "${dep}"`);
}
}
}
// Cycle detection (Kahn's algorithm)
const inDegree = {};
const adj = {};
for (const s of this.stages) {
if (!s.id) continue;
inDegree[s.id] = 0;
adj[s.id] = [];
}
for (const s of this.stages) {
if (!s.id) continue;
for (const dep of s.depends_on) {
if (adj[dep]) {
adj[dep].push(s.id);
inDegree[s.id]++;
}
}
}
const queue = Object.keys(inDegree).filter((k) => inDegree[k] === 0);
let visited = 0;
while (queue.length > 0) {
const node = queue.shift();
visited++;
for (const next of adj[node] || []) {
inDegree[next]--;
if (inDegree[next] === 0) queue.push(next);
}
}
if (visited < Object.keys(inDegree).length) {
errors.push("Cycle detected in stage dependencies");
}
for (let i = 0; i < this.stages.length; i++) {
if (!this.stages[i].id.trim()) {
errors.push(`Stage ${i + 1} has no ID`);
}
}
return errors;
}
_computeLevels() {
const byId = {};
for (const s of this.stages) {
if (s.id) byId[s.id] = s;
}
const levels = {};
const visited = new Set();
const getLevel = (id) => {
if (levels[id] !== undefined) return levels[id];
if (visited.has(id)) return 0;
visited.add(id);
const s = byId[id];
if (!s || s.depends_on.length === 0) {
levels[id] = 0;
return 0;
}
let maxDep = 0;
for (const dep of s.depends_on) {
if (byId[dep]) {
maxDep = Math.max(maxDep, getLevel(dep) + 1);
}
}
levels[id] = maxDep;
return maxDep;
};
for (const s of this.stages) {
if (s.id) getLevel(s.id);
}
return levels;
}
_render() {
const errors = this._validate();
if (!this._readonly) this._sync();
this.innerHTML = "";
this.className = "block";
// Readonly mode: just show the DAG
if (this._readonly) {
if (this.stages.length > 0) {
const canvas = el("div", "dag-canvas overflow-x-auto");
this._renderDag(canvas);
this.append(canvas);
} else {
this.append(el("p", "text-xs text-gray-400 italic", "No stages defined"));
}
return;
}
// Mode toggle
const toolbar = el("div", "flex items-center gap-2 mb-3");
const builderBtn = el(
"button",
`text-xs px-2.5 py-1 rounded border ${this._mode === "builder" ? "bg-gray-900 text-white border-gray-900" : "border-gray-300 text-gray-600 hover:bg-gray-50"}`,
"Builder"
);
builderBtn.type = "button";
builderBtn.onclick = () => {
if (this._mode === "json") {
const ta = this.querySelector(".json-editor");
if (ta) {
try {
const parsed = JSON.parse(ta.value);
this.stages = this._parseStages(parsed);
this._rawJson = null;
} catch (e) {
this._rawJson = ta.value;
}
}
this._mode = "builder";
this._render();
}
};
const jsonBtn = el(
"button",
`text-xs px-2.5 py-1 rounded border ${this._mode === "json" ? "bg-gray-900 text-white border-gray-900" : "border-gray-300 text-gray-600 hover:bg-gray-50"}`,
"JSON"
);
jsonBtn.type = "button";
jsonBtn.onclick = () => {
this._mode = "json";
this._render();
};
toolbar.append(builderBtn, jsonBtn);
if (this._mode === "builder" && this.stages.length > 0) {
const stageCount = el("span", "text-xs text-gray-400 ml-auto", `${this.stages.length} stage${this.stages.length !== 1 ? "s" : ""}`);
toolbar.append(stageCount);
}
this.append(toolbar);
if (this._mode === "json") {
this._renderJsonMode();
} else {
this._renderBuilderMode(errors);
}
}
_renderJsonMode() {
const target = this._target();
const currentJson = this._rawJson || (target ? target.value : "") || "[]";
const ta = el("textarea", "json-editor w-full border border-gray-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900 resize-y");
ta.rows = 12;
ta.value = currentJson;
ta.spellcheck = false;
ta.oninput = () => {
const t = this._target();
if (t) t.value = ta.value;
this._updateJsonErrors(ta.value);
};
const errBox = el("div", "json-errors mt-2");
this.append(ta, errBox);
this._updateJsonErrors(currentJson);
}
_updateJsonErrors(value) {
const errBox = this.querySelector(".json-errors");
if (!errBox) return;
errBox.innerHTML = "";
if (!value.trim()) return;
try {
const parsed = JSON.parse(value);
const stages = Array.isArray(parsed) ? parsed : (parsed.stages || []);
const ids = stages.map((s) => s.id || s.name).filter(Boolean);
if (new Set(ids).size !== ids.length) {
errBox.append(el("p", "text-xs text-amber-600", "Warning: duplicate stage IDs"));
}
} catch (e) {
errBox.append(el("p", "text-xs text-red-600", "Invalid JSON: " + e.message));
}
}
_renderBuilderMode(errors) {
if (this.stages.length > 0) {
const dagBox = el("div", "mb-4 border border-gray-200 rounded-lg overflow-hidden");
const canvas = el("div", "dag-canvas p-4 bg-gray-50 overflow-x-auto");
canvas.style.minHeight = "80px";
this._renderDag(canvas);
dagBox.append(canvas);
this.append(dagBox);
}
const list = el("div", "space-y-2 mb-3");
for (let i = 0; i < this.stages.length; i++) {
list.append(this._renderStageCard(i));
}
this.append(list);
if (errors.length > 0) {
const errBox = el("div", "mb-3 p-3 bg-red-50 border border-red-200 rounded-md");
for (const err of errors) {
errBox.append(el("p", "text-xs text-red-700", err));
}
this.append(errBox);
}
const addBtn = el("button", "text-sm px-3 py-1.5 rounded border border-dashed border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-700 w-full", "+ Add stage");
addBtn.type = "button";
addBtn.onmousedown = (e) => e.preventDefault();
addBtn.onclick = () => {
clearTimeout(this._blurTimer);
this.stages.push({ id: "", depends_on: [], config: { Deploy: { environment: "" } } });
this._render();
requestAnimationFrame(() => {
const inputs = this.querySelectorAll('input[data-field="id"]');
if (inputs.length) inputs[inputs.length - 1].focus();
});
};
this.append(addBtn);
}
_renderStageCard(index) {
const stage = this.stages[index];
const type = this._stageType(stage.config);
const otherIds = this.stages
.map((s, i) => (i !== index && s.id.trim() ? s.id.trim() : null))
.filter(Boolean);
const card = el("div", "border border-gray-200 rounded-md bg-white");
// Header row
const header = el("div", "flex items-center gap-2 px-3 py-2");
const badge = el("span", "text-xs font-mono text-gray-400 w-5 shrink-0", `${index + 1}`);
// ID input
const idInput = el("input", "flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400 min-w-0");
idInput.type = "text";
idInput.value = stage.id;
idInput.placeholder = "stage id";
idInput.dataset.field = "id";
idInput.oninput = () => {
this.stages[index].id = idInput.value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
idInput.value = this.stages[index].id;
this._sync();
this._renderDagIfPresent();
};
idInput.onblur = () => {
this._blurTimer = setTimeout(() => this._render(), 150);
};
// Type select (deploy / wait)
const typeSelect = el("select", "border border-gray-200 rounded px-2 py-1 text-xs bg-white shrink-0");
for (const t of ["deploy", "wait"]) {
const opt = document.createElement("option");
opt.value = t;
opt.textContent = t;
opt.selected = type === t;
typeSelect.append(opt);
}
typeSelect.onmousedown = (e) => e.stopPropagation();
typeSelect.onchange = () => {
clearTimeout(this._blurTimer);
if (typeSelect.value === "wait") {
this.stages[index].config = { Wait: { duration_seconds: 0 } };
} else {
this.stages[index].config = { Deploy: { environment: "" } };
}
this._render();
};
// Remove button
const removeBtn = el("button", "text-gray-400 hover:text-red-500 shrink-0 p-1");
removeBtn.type = "button";
removeBtn.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>`;
removeBtn.title = "Remove stage";
removeBtn.onmousedown = (e) => e.preventDefault();
removeBtn.onclick = () => {
clearTimeout(this._blurTimer);
const removedId = this.stages[index].id;
this.stages.splice(index, 1);
for (const s of this.stages) {
s.depends_on = s.depends_on.filter((d) => d !== removedId);
}
this._render();
};
header.append(badge, idInput, typeSelect, removeBtn);
card.append(header);
// Config row (type-specific fields)
const configRow = el("div", "px-3 pb-2 flex items-center gap-2 flex-wrap");
if (type === "deploy") {
const envLabel = el("span", "text-xs text-gray-500 shrink-0", "env:");
const envInput = el("input", "border border-gray-200 rounded px-2 py-1 text-xs w-32 focus:outline-none focus:ring-1 focus:ring-gray-400");
envInput.type = "text";
envInput.value = (stage.config.Deploy && stage.config.Deploy.environment) || "";
envInput.placeholder = "environment";
envInput.onmousedown = (e) => e.stopPropagation();
envInput.oninput = () => {
if (!this.stages[index].config.Deploy) this.stages[index].config = { Deploy: { environment: "" } };
this.stages[index].config.Deploy.environment = envInput.value.trim();
this._sync();
};
envInput.onblur = () => {
this._blurTimer = setTimeout(() => this._render(), 150);
};
configRow.append(envLabel, envInput);
} else if (type === "wait") {
const durLabel = el("span", "text-xs text-gray-500 shrink-0", "wait:");
const durInput = el("input", "border border-gray-200 rounded px-2 py-1 text-xs w-20 focus:outline-none focus:ring-1 focus:ring-gray-400");
durInput.type = "number";
durInput.min = "0";
durInput.value = (stage.config.Wait && stage.config.Wait.duration_seconds) || 0;
durInput.placeholder = "seconds";
durInput.onmousedown = (e) => e.stopPropagation();
durInput.oninput = () => {
if (!this.stages[index].config.Wait) this.stages[index].config = { Wait: { duration_seconds: 0 } };
this.stages[index].config.Wait.duration_seconds = parseInt(durInput.value) || 0;
this._sync();
};
durInput.onblur = () => {
this._blurTimer = setTimeout(() => this._render(), 150);
};
const secLabel = el("span", "text-xs text-gray-400", "seconds");
configRow.append(durLabel, durInput, secLabel);
}
card.append(configRow);
// Dependencies row
if (otherIds.length > 0) {
const depsRow = el("div", "px-3 pb-2 flex items-center gap-2 flex-wrap");
const label = el("span", "text-xs text-gray-500 shrink-0", "after:");
depsRow.append(label);
for (const dep of otherIds) {
const isSelected = stage.depends_on.includes(dep);
const chip = el(
"button",
`text-xs px-2 py-0.5 rounded-full border transition-colors ${isSelected ? "bg-gray-900 text-white border-gray-900" : "border-gray-300 text-gray-500 hover:border-gray-400"}`,
dep
);
chip.type = "button";
chip.onmousedown = (e) => e.preventDefault();
chip.onclick = () => {
clearTimeout(this._blurTimer);
if (isSelected) {
this.stages[index].depends_on = this.stages[index].depends_on.filter((d) => d !== dep);
} else {
this.stages[index].depends_on.push(dep);
}
this._render();
};
depsRow.append(chip);
}
card.append(depsRow);
}
return card;
}
_renderDagIfPresent() {
const canvas = this.querySelector(".dag-canvas");
if (canvas) this._renderDag(canvas);
}
_renderDag(canvas) {
canvas.innerHTML = "";
const named = this.stages.filter((s) => s.id.trim());
if (named.length === 0) {
canvas.append(el("p", "text-xs text-gray-400 italic", "Add stages to see the pipeline graph"));
return;
}
const levels = this._computeLevels();
const maxLevel = Math.max(0, ...Object.values(levels));
const columns = [];
for (let l = 0; l <= maxLevel; l++) columns.push([]);
for (const s of named) {
const lvl = levels[s.id] || 0;
columns[lvl].push(s);
}
const svgNS = "http://www.w3.org/2000/svg";
const NODE_W = 120;
const NODE_H = 40;
const COL_GAP = 60;
const ROW_GAP = 12;
const positions = {};
let totalW = 0;
let totalH = 0;
for (let col = 0; col <= maxLevel; col++) {
const stages = columns[col];
for (let row = 0; row < stages.length; row++) {
const x = col * (NODE_W + COL_GAP);
const y = row * (NODE_H + ROW_GAP);
positions[stages[row].id] = { x, y };
totalW = Math.max(totalW, x + NODE_W);
totalH = Math.max(totalH, y + NODE_H);
}
}
const PAD = 8;
const svgW = totalW + PAD * 2;
const svgH = totalH + PAD * 2;
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("width", svgW);
svg.setAttribute("height", svgH);
svg.style.display = "block";
// Arrowhead marker
const defs = document.createElementNS(svgNS, "defs");
const marker = document.createElementNS(svgNS, "marker");
marker.setAttribute("id", "pb-arrow");
marker.setAttribute("viewBox", "0 0 10 10");
marker.setAttribute("refX", "10");
marker.setAttribute("refY", "5");
marker.setAttribute("markerWidth", "6");
marker.setAttribute("markerHeight", "6");
marker.setAttribute("orient", "auto-start-reverse");
const arrowPath = document.createElementNS(svgNS, "path");
arrowPath.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
arrowPath.setAttribute("fill", "#9ca3af");
marker.append(arrowPath);
defs.append(marker);
svg.append(defs);
// Draw edges
for (const s of named) {
const to = positions[s.id];
if (!to) continue;
for (const dep of s.depends_on) {
const from = positions[dep];
if (!from) continue;
const line = document.createElementNS(svgNS, "line");
line.setAttribute("x1", from.x + NODE_W + PAD);
line.setAttribute("y1", from.y + NODE_H / 2 + PAD);
line.setAttribute("x2", to.x + PAD);
line.setAttribute("y2", to.y + NODE_H / 2 + PAD);
line.setAttribute("stroke", "#d1d5db");
line.setAttribute("stroke-width", "2");
line.setAttribute("marker-end", "url(#pb-arrow)");
svg.append(line);
}
}
// Draw nodes
const TYPE_COLORS = {
deploy: { bg: "#dbeafe", border: "#93c5fd", text: "#1e40af" },
wait: { bg: "#fef3c7", border: "#fcd34d", text: "#92400e" },
};
for (const s of named) {
const pos = positions[s.id];
if (!pos) continue;
const type = this._stageType(s.config);
const colors = TYPE_COLORS[type] || TYPE_COLORS.deploy;
const label = this._configLabel(s.config);
const rect = document.createElementNS(svgNS, "rect");
rect.setAttribute("x", pos.x + PAD);
rect.setAttribute("y", pos.y + PAD);
rect.setAttribute("width", NODE_W);
rect.setAttribute("height", NODE_H);
rect.setAttribute("rx", "6");
rect.setAttribute("fill", colors.bg);
rect.setAttribute("stroke", colors.border);
rect.setAttribute("stroke-width", "1.5");
svg.append(rect);
// Stage ID text
const text = document.createElementNS(svgNS, "text");
text.setAttribute("x", pos.x + NODE_W / 2 + PAD);
text.setAttribute("y", pos.y + NODE_H / 2 + PAD + (label ? -4 : 0));
text.setAttribute("text-anchor", "middle");
text.setAttribute("dominant-baseline", "middle");
text.setAttribute("fill", colors.text);
text.setAttribute("font-size", "12");
text.setAttribute("font-weight", "600");
text.textContent = s.id.length > 14 ? s.id.slice(0, 13) + "…" : s.id;
svg.append(text);
// Config label (environment or duration)
if (label) {
const sub = document.createElementNS(svgNS, "text");
sub.setAttribute("x", pos.x + NODE_W / 2 + PAD);
sub.setAttribute("y", pos.y + NODE_H / 2 + 10 + PAD);
sub.setAttribute("text-anchor", "middle");
sub.setAttribute("dominant-baseline", "middle");
sub.setAttribute("fill", colors.text);
sub.setAttribute("font-size", "9");
sub.setAttribute("opacity", "0.7");
sub.textContent = label;
svg.append(sub);
}
}
canvas.append(svg);
}
}
function el(tag, className, text) {
const e = document.createElement(tag);
if (className) e.className = className;
if (text) e.textContent = text;
return e;
}
customElements.define("pipeline-builder", PipelineBuilder);

View File

@@ -2,34 +2,30 @@
* <swim-lanes> web component
*
* Renders colored vertical bars alongside a release timeline.
* Each bar grows from the BOTTOM of the timeline upward to the top edge
* of the last release card deployed to that environment.
* Labels are rendered at the bottom of each bar, rotated vertically.
* Bars grow from the BOTTOM of the timeline upward to the dot position
* (avatar center) of the relevant release card.
*
* Usage:
* <swim-lanes>
* <div data-lane="staging"></div>
* <div data-lane="prod"></div>
* <div data-swimlane-timeline>
* <div data-release data-envs="staging,prod">...</div>
* <div data-release data-envs="staging">...</div>
* </div>
* </swim-lanes>
* In-flight deployments (QUEUED/RUNNING/ASSIGNED) show a hatched segment
* with direction arrows: ▲ for forward deploy, ▼ for rollback.
*
* data-envs format: "env:STATUS,env:STATUS" e.g. "staging:SUCCEEDED,prod:QUEUED"
*/
const ENV_COLORS = {
prod: ["#f472b6", "#ec4899"],
production: ["#f472b6", "#ec4899"],
preprod: ["#fdba74", "#f97316"],
"pre-prod": ["#fdba74", "#f97316"],
staging: ["#fbbf24", "#ca8a04"],
stage: ["#fbbf24", "#ca8a04"],
dev: ["#a78bfa", "#7c3aed"],
development: ["#a78bfa", "#7c3aed"],
test: ["#67e8f9", "#0891b2"],
prod: ["#ec4899", "#fce7f3"],
production: ["#ec4899", "#fce7f3"],
preprod: ["#f97316", "#ffedd5"],
"pre-prod": ["#f97316", "#ffedd5"],
staging: ["#eab308", "#fef9c3"],
stage: ["#eab308", "#fef9c3"],
dev: ["#8b5cf6", "#ede9fe"],
development: ["#8b5cf6", "#ede9fe"],
test: ["#06b6d4", "#cffafe"],
};
const DEFAULT_COLORS = ["#d1d5db", "#9ca3af"];
const DEFAULT_COLORS = ["#6b7280", "#e5e7eb"];
const IN_FLIGHT = new Set(["QUEUED", "RUNNING", "ASSIGNED"]);
const DEPLOYED = new Set(["SUCCEEDED"]);
function envColors(name) {
const lower = name.toLowerCase();
@@ -40,17 +36,80 @@ function envColors(name) {
return DEFAULT_COLORS;
}
function parseEnvs(raw) {
if (!raw) return [];
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((entry) => {
const colon = entry.indexOf(":");
if (colon === -1) return { env: entry, status: "SUCCEEDED" };
return { env: entry.slice(0, colon), status: entry.slice(colon + 1) };
});
}
function dotY(card, timelineTop) {
const avatar = card.querySelector("[data-avatar]");
const anchor = avatar || card;
const r = anchor.getBoundingClientRect();
return r.top + r.height / 2 - timelineTop;
}
/** Create an inline SVG data URL for a diagonal hatch pattern */
function hatchPattern(color, bgColor) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8">
<rect width="8" height="8" fill="${bgColor}"/>
<path d="M-2,2 l4,-4 M0,8 l8,-8 M6,10 l4,-4" stroke="${color}" stroke-width="1.5" opacity="0.6"/>
</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
// Inject CSS once
if (!document.getElementById("swim-lane-styles")) {
const style = document.createElement("style");
style.id = "swim-lane-styles";
style.textContent = `
@keyframes lane-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.lane-pulse {
animation: lane-pulse 2s ease-in-out infinite;
}
.lane-arrow {
font-size: 9px;
line-height: 1;
font-weight: 700;
text-align: center;
width: 100%;
position: absolute;
left: 0;
z-index: 3;
pointer-events: none;
}
`;
document.head.appendChild(style);
}
const BAR_WIDTH = 20;
const BAR_GAP = 4;
const DOT_SIZE = 12;
class SwimLanes extends HTMLElement {
connectedCallback() {
this.style.display = "flex";
this._render();
this._ro = new ResizeObserver(() => this._render());
const timeline = this.querySelector("[data-swimlane-timeline]");
if (timeline) {
this._ro.observe(timeline);
// Re-render when details elements are toggled (show/hide commits)
timeline.addEventListener("toggle", () => this._render(), true);
}
// Lanes live in [data-swimlane-gutter], a CSS grid column to the
// left of the timeline. The grid column width is pre-set in the
// template (lane_count * 18 + 8 px) so there is no layout shift.
requestAnimationFrame(() => {
this._render();
this._ro = new ResizeObserver(() => this._render());
const timeline = this.querySelector("[data-swimlane-timeline]");
if (timeline) {
this._ro.observe(timeline);
timeline.addEventListener("toggle", () => this._render(), true);
}
});
}
disconnectedCallback() {
@@ -65,37 +124,70 @@ class SwimLanes extends HTMLElement {
if (cards.length === 0) return;
const timelineRect = timeline.getBoundingClientRect();
const lanes = Array.from(this.querySelectorAll("[data-lane]"));
if (timelineRect.height === 0) return;
const gutter = this.querySelector("[data-swimlane-gutter]");
const lanes = gutter
? Array.from(gutter.querySelectorAll("[data-lane]"))
: Array.from(this.querySelectorAll("[data-lane]"));
for (const lane of lanes) {
const env = lane.dataset.lane;
const [barColor, labelColor] = envColors(env);
const [barColor, lightColor] = envColors(env);
// Find the LAST (bottommost) card deployed to this env
let lastCard = null;
for (const card of cards) {
const envs = (card.dataset.envs || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (envs.includes(env)) lastCard = card;
let deployedCard = null;
let deployedIdx = -1;
let flightCard = null;
let flightIdx = -1;
for (let i = 0; i < cards.length; i++) {
const entries = parseEnvs(cards[i].dataset.envs);
for (const entry of entries) {
if (entry.env !== env) continue;
if (DEPLOYED.has(entry.status) && !deployedCard) {
deployedCard = cards[i];
deployedIdx = i;
}
if (IN_FLIGHT.has(entry.status) && !flightCard) {
flightCard = cards[i];
flightIdx = i;
}
}
}
// Bar height: from bottom of timeline up to top of the last deployed card
let barHeight = 0;
if (lastCard) {
const cardRect = lastCard.getBoundingClientRect();
barHeight = timelineRect.bottom - cardRect.top;
const timelineH = timelineRect.height;
// Card top edge (Y relative to timeline) — bars extend to the card top
const deployedTop = deployedCard
? deployedCard.getBoundingClientRect().top - timelineRect.top
: null;
const flightTop = flightCard
? flightCard.getBoundingClientRect().top - timelineRect.top
: null;
// Dot center Y — used for arrow placement
const flightDot = flightCard
? dotY(flightCard, timelineRect.top)
: null;
// Solid bar: from bottom up to the card top of the LOWER card.
// If both exist, only go to whichever is lower (further down) to avoid overlap.
let solidBarFromBottom = 0;
if (deployedTop !== null && flightTop !== null) {
const lowerTop = Math.max(deployedTop, flightTop);
solidBarFromBottom = timelineH - lowerTop;
} else if (deployedTop !== null) {
solidBarFromBottom = timelineH - deployedTop;
}
// Style the lane container
lane.style.width = "14px";
lane.style.marginRight = "4px";
// Style lane container — width/gap only; height comes from the grid row
lane.style.width = BAR_WIDTH + "px";
lane.style.marginRight = BAR_GAP + "px";
lane.style.position = "relative";
lane.style.minHeight = timelineRect.height + "px";
lane.style.flexShrink = "0";
// Create or update bar (anchored to bottom)
const hasHatch = !!flightCard;
const hasSolid = solidBarFromBottom > 0;
const R = "9999px";
// ── Solid bar ──
let bar = lane.querySelector(".lane-bar");
if (!bar) {
bar = document.createElement("div");
@@ -104,26 +196,85 @@ class SwimLanes extends HTMLElement {
bar.style.bottom = "0";
bar.style.left = "0";
bar.style.width = "100%";
bar.style.borderRadius = "9999px";
lane.appendChild(bar);
}
bar.style.height = barHeight + "px";
bar.style.height = Math.max(solidBarFromBottom, 0) + "px";
bar.style.backgroundColor = barColor;
// Round bottom always; round top only if no hatch connects above
bar.style.borderRadius = hasHatch
? `0 0 ${R} ${R}`
: R;
// ── Hatched segment for in-flight ──
let hatch = lane.querySelector(".lane-hatch");
let arrow = lane.querySelector(".lane-arrow");
if (flightCard) {
const isForward = deployedIdx === -1 || flightIdx < deployedIdx;
// Hatched segment spans between the two card tops (or bottom of timeline)
const anchorY = deployedTop !== null ? deployedTop : timelineH;
const topY = Math.min(anchorY, flightTop);
const bottomY = Math.max(anchorY, flightTop);
const segHeight = bottomY - topY;
if (!hatch) {
hatch = document.createElement("div");
hatch.className = "lane-hatch lane-pulse";
hatch.style.position = "absolute";
hatch.style.left = "0";
hatch.style.width = "100%";
hatch.style.backgroundSize = "8px 8px";
hatch.style.backgroundRepeat = "repeat";
lane.appendChild(hatch);
}
hatch.style.backgroundImage = isForward
? hatchPattern(barColor, lightColor)
: hatchPattern("#f59e0b", "#fef3c7");
hatch.style.top = topY + "px";
hatch.style.height = Math.max(segHeight, 4) + "px";
hatch.style.display = "";
// Round top always; round bottom only if no solid bar connects below
hatch.style.borderRadius = hasSolid
? `${R} ${R} 0 0`
: R;
// Direction arrow:
// Forward (▲): shown at the in-flight card (destination)
// Rollback (▼): shown at the deployed card (source we're rolling back from)
const arrowDotY = isForward
? flightDot
: dotY(deployedCard, timelineRect.top);
if (!arrow) {
arrow = document.createElement("div");
arrow.className = "lane-arrow";
lane.appendChild(arrow);
}
arrow.textContent = isForward ? "\u25B2" : "\u25BC";
arrow.style.color = isForward ? barColor : "#f59e0b";
arrow.style.top = arrowDotY - 5 + "px";
arrow.style.display = "";
} else {
if (hatch) hatch.style.display = "none";
if (arrow) arrow.style.display = "none";
}
// ── Dots ──
// The arrow replaces the dot on one card:
// Forward: arrow on in-flight card (destination)
// Rollback: arrow on deployed card (source)
const arrowCard = flightCard
? (deployedIdx === -1 || flightIdx < deployedIdx ? flightCard : deployedCard)
: null;
// Place dots on the lane for each card deployed to this env
const existingDots = lane.querySelectorAll(".lane-dot");
let dotIndex = 0;
for (const card of cards) {
const envs = (card.dataset.envs || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!envs.includes(env)) continue;
const entries = parseEnvs(card.dataset.envs);
const match = entries.find((e) => e.env === env);
if (!match) continue;
if (card === arrowCard) continue; // arrow shown instead of dot
const avatar = card.querySelector("[data-avatar]");
const anchor = avatar || card;
const anchorRect = anchor.getBoundingClientRect();
const centerY = anchorRect.top + anchorRect.height / 2 - timelineRect.top;
const cy = dotY(card, timelineRect.top);
let dot = existingDots[dotIndex];
if (!dot) {
@@ -132,41 +283,23 @@ class SwimLanes extends HTMLElement {
dot.style.position = "absolute";
dot.style.left = "50%";
dot.style.transform = "translateX(-50%)";
dot.style.width = "8px";
dot.style.height = "8px";
dot.style.width = DOT_SIZE + "px";
dot.style.height = DOT_SIZE + "px";
dot.style.borderRadius = "50%";
dot.style.backgroundColor = "#fff";
dot.style.border = "2px solid " + barColor;
dot.style.zIndex = "1";
dot.style.zIndex = "2";
lane.appendChild(dot);
}
dot.style.top = centerY - 4 + "px";
dot.style.borderColor = barColor;
dot.style.top = cy - DOT_SIZE / 2 + "px";
dot.style.backgroundColor = "#fff";
dot.style.border = "2px solid " + barColor;
dot.classList.remove("lane-pulse");
dotIndex++;
}
// Remove extra dots from previous renders
for (let i = dotIndex; i < existingDots.length; i++) {
existingDots[i].remove();
}
// Create or update label (at the very bottom, below bars)
let label = lane.querySelector(".lane-label");
if (!label) {
label = document.createElement("span");
label.className = "lane-label";
label.style.position = "absolute";
label.style.bottom = "-4px";
label.style.left = "50%";
label.style.writingMode = "vertical-lr";
label.style.transform = "translateX(-50%) translateY(100%) rotate(180deg)";
label.style.fontSize = "10px";
label.style.fontWeight = "500";
label.style.whiteSpace = "nowrap";
label.style.paddingTop = "6px";
lane.appendChild(label);
}
label.textContent = env;
label.style.color = labelColor;
// Labels are rendered server-side above the gutter (no JS needed).
}
}
}

View File

@@ -7,7 +7,7 @@
<meta name="description" content="{{ description }}">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="bg-white text-gray-900 antialiased">
<body class="bg-white text-gray-900 antialiased flex flex-col min-h-screen">
{% if user is defined and user %}
{# ── Authenticated nav ─────────────────────────────────────── #}
<nav class="border-b border-gray-200 pt-3">
@@ -32,13 +32,31 @@
{% else %}
<a href="/orgs/{{ current_org }}/projects" class="font-medium text-gray-900 hover:text-black">{{ current_org }}</a>
{% endif %}
{% if project_name is defined and project_name %}
{% if projects is defined and projects | length > 0 %}
<span class="text-gray-300">/</span>
<details class="relative">
<summary class="font-medium text-gray-900 hover:text-black cursor-pointer list-none truncate">
{% if project_name is defined and project_name %}{{ project_name }}{% else %}Select project{% endif %}
<svg class="inline w-3 h-3 ml-0.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</summary>
<div class="absolute left-0 mt-1 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-20 py-1">
{% for p in projects %}
<a href="/orgs/{{ current_org }}/projects/{{ p }}" class="block px-3 py-1.5 text-sm hover:bg-gray-50{% if project_name is defined and p == project_name %} font-medium bg-gray-50{% endif %}">{{ p }}</a>
{% endfor %}
</div>
</details>
{% elif project_name is defined and project_name %}
<span class="text-gray-300">/</span>
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}" class="font-medium text-gray-900 hover:text-black truncate">{{ project_name }}</a>
{% endif %}
{% endif %}
</div>
<div class="flex items-center gap-4 shrink-0">
<div class="flex items-center gap-3 shrink-0">
<a href="/notifications" class="text-gray-400 hover:text-gray-900 relative" title="Notifications">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
</a>
<a href="/settings/account" class="text-sm text-gray-500 hover:text-gray-900">{{ user.username }}</a>
<form method="POST" action="/logout" class="inline">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
@@ -49,18 +67,21 @@
{# Tab navigation #}
<div class="max-w-6xl mx-auto px-4 mt-2">
<div class="flex gap-1 -mb-px overflow-x-auto">
{% if current_org is defined and current_org %}
{# Org-scoped tabs #}
{% if project_name is defined and project_name %}
{# ── Project-level tabs ─────────────────────────────── #}
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}" class="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 == 'project_overview' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}/releases" class="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 == 'project_releases' %} text-gray-900 border-gray-900{% endif %}">Releases</a>
{% elif current_org is defined and current_org %}
{# ── Org-level tabs ─────────────────────────────────── #}
<a href="/dashboard" class="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 == 'dashboard' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
<a href="/orgs/{{ current_org }}/projects" class="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 }}/releases" class="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 == 'releases' %} text-gray-900 border-gray-900{% endif %}">Releases</a>
<a href="/orgs/{{ current_org }}/settings/members" class="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="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 }}/usage" class="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="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>
<a href="/settings/account" class="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 == 'account' %} text-gray-900 border-gray-900{% endif %}">Settings</a>
{% else %}
{# Global tabs (dashboard, settings) #}
{# ── Global tabs (no org context) ───────────────────── #}
<a href="/dashboard" class="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 == 'dashboard' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
<a href="/settings/tokens" class="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>
<a href="/settings/account" class="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 == 'account' %} text-gray-900 border-gray-900{% endif %}">Settings</a>
@@ -82,11 +103,11 @@
</nav>
{% endif %}
<main>
<main class="flex-1">
{% block content %}{% endblock %}
</main>
<footer class="border-t border-gray-200 mt-24">
<footer class="border-t border-gray-200 mt-auto pt-0">
<div class="max-w-6xl mx-auto px-4 py-12">
<div class="grid grid-cols-2 md:grid-cols-3 gap-8">
<div>
@@ -115,5 +136,13 @@
</div>
</div>
</footer>
<script>
document.querySelectorAll('time[datetime]').forEach(function(el) {
try {
var d = new Date(el.getAttribute('datetime'));
if (!isNaN(d)) el.title = d.toLocaleString();
} catch(e) {}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,235 @@
{% from "components/timestamp.html.jinja" import timeago as ts %}
{% if releases | length > 0 %}
<div class="space-y-3">
{% for r in releases %}
<div class="border border-gray-200 rounded-lg overflow-hidden">
{# ── Header row ─────────────────────────────────────────── #}
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0 flex-1">
{# Status dot #}
{% if r.summary_status == "RUNNING" %}
<span class="w-2.5 h-2.5 rounded-full bg-yellow-400 animate-pulse shrink-0"></span>
{% elif r.summary_status == "QUEUED" %}
<span class="w-2.5 h-2.5 rounded-full bg-blue-400 shrink-0"></span>
{% elif r.summary_status == "FAILED" %}
<span class="w-2.5 h-2.5 rounded-full bg-red-500 shrink-0"></span>
{% elif r.summary_status == "SUCCEEDED" %}
<span class="w-2.5 h-2.5 rounded-full bg-green-500 shrink-0"></span>
{% elif r.summary_status == "TIMED_OUT" %}
<span class="w-2.5 h-2.5 rounded-full bg-orange-400 shrink-0"></span>
{% elif r.summary_status == "CANCELLED" %}
<span class="w-2.5 h-2.5 rounded-full bg-gray-400 shrink-0"></span>
{% else %}
<span class="w-2.5 h-2.5 rounded-full bg-gray-300 shrink-0"></span>
{% endif %}
<a href="/orgs/{{ r.org }}/projects/{{ r.project }}/releases/{{ r.slug }}" class="font-medium text-gray-900 hover:text-black truncate">
{% if r.commit_message %}{{ r.commit_message }}{% else %}{{ r.title }}{% endif %}
</a>
</div>
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0 flex-wrap">
<a href="/orgs/{{ r.org }}/projects/{{ r.project }}" class="hover:underline">{{ r.org }}/{{ r.project }}</a>
{% if r.branch %}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z"/></svg>
{{ r.branch }}
</span>
{% endif %}
{% if r.commit_sha %}
<span class="font-mono">{{ r.commit_sha }}</span>
{% endif %}
<time>{{ ts(r.created_at) }}</time>
{% if r.source_user %}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<a href="/users/{{ r.source_user }}" class="hover:underline">{{ r.source_user }}</a>
</span>
{% endif %}
</div>
</div>
{# ── Summary + expandable details ────────────────────── #}
<details class="border-t border-gray-100 group" data-slug="{{ r.slug }}" {% if r.summary_status == "RUNNING" or r.summary_status == "QUEUED" or r.summary_status == "FAILED" %}open{% endif %}>
<summary class="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer list-none hover:bg-gray-50 flex-wrap">
{# Pipeline / env summary #}
{% if r.has_pipeline and r.pipeline_stages | length > 0 %}
{# Pipeline icon #}
<svg class="w-3.5 h-3.5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
{# Stage badges #}
{% for stage in r.pipeline_stages %}
{% if stage.stage_type == "deploy" %}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100">
{% if stage.status == "SUCCEEDED" %}
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
{% elif stage.status == "RUNNING" %}
<span class="w-1.5 h-1.5 rounded-full bg-yellow-500 animate-pulse"></span>
{% elif stage.status == "QUEUED" %}
<span class="w-1.5 h-1.5 rounded-full bg-blue-400"></span>
{% elif stage.status == "FAILED" %}
<span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
{% else %}
<span class="w-1.5 h-1.5 rounded-full bg-gray-300"></span>
{% endif %}
{{ stage.environment }}
</span>
{% endif %}
{% endfor %}
{# Done count #}
{% set ns = namespace(done=0, total=0) %}
{% for stage in r.pipeline_stages %}
{% set ns.total = ns.total + 1 %}
{% if stage.status == "SUCCEEDED" %}{% set ns.done = ns.done + 1 %}{% endif %}
{% endfor %}
<span class="text-xs text-gray-400">{{ ns.done }}/{{ ns.total }}</span>
{% elif r.has_pipeline %}
{# Pipeline exists but no stages yet #}
<svg class="w-3.5 h-3.5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
{% set ns = namespace(all_done=true) %}
{% for g in r.env_groups %}{% if g.status != "SUCCEEDED" %}{% set ns.all_done = false %}{% endif %}{% endfor %}
{% if r.env_groups | length > 0 and ns.all_done %}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-500 text-sm">Deployed</span>
{% else %}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-blue-600 text-sm">Queued</span>
{% endif %}
{% elif r.env_groups | length > 0 %}
{# No pipeline, show env groups #}
{% for g in r.env_groups %}
{% if g.status == "RUNNING" %}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
<span class="text-yellow-700 text-sm">Deploying to</span>
{% elif g.status == "QUEUED" %}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-blue-600 text-sm">Queued for</span>
{% elif g.status == "FAILED" %}
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-red-600 text-sm">Failed on</span>
{% elif g.status == "SUCCEEDED" %}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-500 text-sm">Deployed to</span>
{% endif %}
{% for env in g.envs %}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100">{{ env }}</span>
{% endfor %}
{% endfor %}
{% else %}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-400 text-sm">Pending</span>
{% endif %}
<svg class="w-3 h-3 text-gray-400 shrink-0 ml-auto transition-transform group-open:rotate-90" 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>
</summary>
{# ── Release details ─────────────────────────────────── #}
<div class="px-4 py-3 border-t border-gray-100 space-y-3">
{% if r.description %}
<p class="text-sm text-gray-700">{{ r.description }}</p>
{% endif %}
<div class="flex flex-wrap gap-x-6 gap-y-2 text-xs text-gray-500">
<span class="font-mono text-gray-400">{{ r.slug }}</span>
{% if r.version %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">{{ r.version }}</span>
{% endif %}
</div>
</div>
{# ── Pipeline stages ─────────────────────────────────── #}
{% if r.has_pipeline and r.pipeline_stages | length > 0 %}
<div class="border-t border-gray-100">
{% for stage in r.pipeline_stages %}
<div class="px-4 py-2.5 flex items-center gap-3 text-sm {{ 'border-b border-gray-50' if not loop.last else '' }} {{ 'opacity-50' if stage.status == 'PENDING' else '' }}">
{# Status icon #}
{% if stage.status == "SUCCEEDED" %}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% elif stage.status == "RUNNING" %}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{% elif stage.status == "QUEUED" %}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% elif stage.status == "FAILED" %}
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
{% endif %}
{# Stage label #}
{% if stage.stage_type == "deploy" %}
<span class="text-sm {{ 'text-gray-700' if stage.status == 'SUCCEEDED' else 'text-yellow-700' if stage.status == 'RUNNING' else 'text-red-700' if stage.status == 'FAILED' else 'text-gray-400' }}">
{% if stage.status == "SUCCEEDED" %}Deployed to{% elif stage.status == "RUNNING" %}Deploying to{% elif stage.status == "QUEUED" %}Queued for{% elif stage.status == "FAILED" %}Failed on{% elif stage.status == "TIMED_OUT" %}Timed out on{% elif stage.status == "CANCELLED" %}Cancelled{% else %}Deploy to{% endif %}
</span>
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100">
{{ stage.environment }}
</span>
{% elif stage.stage_type == "wait" %}
<span class="text-sm {{ 'text-gray-700' if stage.status == 'SUCCEEDED' else 'text-yellow-700' if stage.status == 'RUNNING' else 'text-gray-400' }}">
{% if stage.status == "SUCCEEDED" %}Waited{% elif stage.status == "RUNNING" %}Waiting{% elif stage.status == "FAILED" %}Wait failed{% elif stage.status == "CANCELLED" %}Wait cancelled{% else %}Wait{% endif %}
{% if stage.duration_seconds %}{{ stage.duration_seconds }}s{% endif %}
</span>
{% endif %}
{# Elapsed time #}
{% if stage.started_at %}
<span class="text-xs text-gray-400 tabular-nums">{{ ts(stage.started_at) }}</span>
{% endif %}
<span class="ml-auto flex items-center gap-1 text-xs text-gray-400 shrink-0">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
pipeline
</span>
</div>
{% endfor %}
</div>
{% endif %}
{# ── Destinations ─────────────────────────────────────── #}
{% if r.destinations | length > 0 %}
{% for dest in r.destinations %}
<div class="px-4 py-2 flex items-center gap-3 text-sm {{ 'border-b border-gray-50' if not loop.last else '' }} border-t border-gray-100">
{# Status icon #}
{% if dest.status == "SUCCEEDED" %}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% elif dest.status == "RUNNING" or dest.status == "ASSIGNED" %}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{% elif dest.status == "QUEUED" %}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% elif dest.status == "FAILED" %}
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% endif %}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100">
{{ dest.environment }}
</span>
<span class="text-gray-400 text-xs">{{ dest.name }}</span>
{% if dest.status == "SUCCEEDED" %}
<span class="text-xs text-green-600">Deployed</span>
{% elif dest.status == "RUNNING" %}
<span class="text-xs text-yellow-600">Deploying</span>
{% elif dest.status == "QUEUED" %}
<span class="text-xs text-blue-600">Queued{% if dest.queue_position %} #{{ dest.queue_position }}{% endif %}</span>
{% elif dest.status == "FAILED" %}
<span class="text-xs text-red-600">Failed</span>
{% endif %}
{% if dest.completed_at %}
<time class="text-xs text-gray-400 ml-auto">{{ ts(dest.completed_at) }}</time>
{% endif %}
</div>
{% endfor %}
{% endif %}
</details>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-16">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
<p class="text-gray-500">No release activity yet.</p>
<p class="text-sm text-gray-400 mt-1">Releases you create will appear here with their deployment status.</p>
</div>
{% endif %}

View File

@@ -0,0 +1,9 @@
{# Reusable timestamp macro.
Renders relative time with local-time tooltip on hover.
Usage: {% from "components/timestamp.html.jinja" import timeago %}
{{ timeago(item.created_at) }}
{{ timeago(item.created_at, class="text-xs text-gray-400") }}
#}
{% macro timeago(value, class="") %}
<time datetime="{{ value }}" class="{{ class }}">{{ value | timeago }}</time>
{% endmacro %}

View File

@@ -1,124 +1,289 @@
{% extends "base.html.jinja" %}
{% from "components/timestamp.html.jinja" import timeago as ts %}
{% block content %}
<section class="max-w-4xl mx-auto px-4 pt-12">
<section class="max-w-4xl mx-auto px-4 pt-12 pb-12">
{# ── Breadcrumb + heading ──────────────────────────────────── #}
<div class="mb-8">
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}" class="text-sm text-gray-500 hover:text-gray-700">&larr; {{ project_name }}</a>
<div class="flex items-center gap-3 mt-1">
<h1 class="text-2xl font-bold">{{ artifact.title }}</h1>
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}" class="hover:text-gray-700">{{ project_name }}</a>
<span class="text-gray-300">/</span>
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/releases" class="hover:text-gray-700">Releases</a>
</div>
<div class="flex items-center gap-3">
{# Status dot #}
{% if summary_status == "RUNNING" %}
<span class="w-3 h-3 rounded-full bg-yellow-400 animate-pulse shrink-0"></span>
{% elif summary_status == "QUEUED" %}
<span class="w-3 h-3 rounded-full bg-blue-400 shrink-0"></span>
{% elif summary_status == "FAILED" %}
<span class="w-3 h-3 rounded-full bg-red-500 shrink-0"></span>
{% elif summary_status == "SUCCEEDED" %}
<span class="w-3 h-3 rounded-full bg-green-500 shrink-0"></span>
{% elif summary_status == "TIMED_OUT" %}
<span class="w-3 h-3 rounded-full bg-orange-400 shrink-0"></span>
{% elif summary_status == "CANCELLED" %}
<span class="w-3 h-3 rounded-full bg-gray-400 shrink-0"></span>
{% else %}
<span class="w-3 h-3 rounded-full bg-gray-300 shrink-0"></span>
{% endif %}
<h1 class="text-2xl font-bold">
{% if artifact.commit_message %}{{ artifact.commit_message }}{% else %}{{ artifact.title }}{% endif %}
</h1>
{% if artifact.version %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-sm font-medium bg-green-100 text-green-800">{{ artifact.version }}</span>
{% endif %}
</div>
<p class="text-sm text-gray-500 mt-1 font-mono">{{ artifact.slug }}</p>
<p class="text-sm font-mono text-gray-400 mt-1">{{ artifact.slug }}</p>
{# ── Metadata pills ────────────────────────────────────── #}
<div class="flex flex-wrap items-center gap-2 mt-3">
{% if artifact.branch %}
<span class="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full bg-blue-50 text-blue-700 border border-blue-200">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
{{ artifact.branch }}
</span>
{% endif %}
{% if artifact.commit_sha %}
<span class="inline-flex items-center gap-1.5 text-xs font-mono px-2.5 py-1 rounded-full bg-gray-100 text-gray-600 border border-gray-200">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
{{ artifact.commit_sha[:8] }}
</span>
{% endif %}
{% if artifact.source_type %}
<span class="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full bg-purple-50 text-purple-700 border border-purple-200">
{% if 'github' in artifact.source_type %}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
{% elif 'gitlab' in artifact.source_type %}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 01-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 014.82 2a.43.43 0 01.58 0 .42.42 0 01.11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0118.6 2a.43.43 0 01.58 0 .42.42 0 01.11.18l2.44 7.51L23 13.45a.84.84 0 01-.35.94z"/></svg>
{% else %}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
{% endif %}
{{ artifact.source_type | replace("_", " ") | replace("-", " ") }}
</span>
{% endif %}
{% if artifact.source_user %}
<a href="/users/{{ artifact.source_user }}" class="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
{{ artifact.source_user }}
</a>
{% endif %}
<span class="text-xs text-gray-400">{{ ts(artifact.created_at) }}</span>
</div>
</div>
{% if artifact.description %}
<div class="mb-6">
{% if artifact.description and not artifact.description is startingwith("Branch:") %}
<div class="mb-8">
<p class="text-gray-700">{{ artifact.description }}</p>
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<!-- Git info -->
{% if artifact.commit_sha or artifact.branch %}
<div class="p-4 border border-gray-200 rounded-lg">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Git</h3>
<dl class="space-y-2 text-sm">
{% if artifact.branch %}
<div class="flex justify-between">
<dt class="text-gray-500">Branch</dt>
<dd class="font-mono text-blue-700">{{ artifact.branch }}</dd>
</div>
{# ── Pipeline stages ───────────────────────────────────────── #}
{% if has_pipeline and pipeline_stages | length > 0 %}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-900 mb-3">Pipeline</h2>
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
{% for stage in pipeline_stages %}
<div class="px-4 py-3 flex items-center gap-3 text-sm {{ 'opacity-50' if stage.status == 'PENDING' else '' }}">
{# Status icon #}
{% if stage.status == "SUCCEEDED" %}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% elif stage.status == "RUNNING" %}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{% elif stage.status == "QUEUED" %}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% elif stage.status == "FAILED" %}
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
{% endif %}
{% if artifact.commit_sha %}
<div class="flex justify-between">
<dt class="text-gray-500">Commit</dt>
<dd class="font-mono">{{ artifact.commit_sha[:8] }}</dd>
</div>
{% endif %}
{% if artifact.commit_message %}
<div class="flex justify-between">
<dt class="text-gray-500">Message</dt>
<dd class="text-gray-700 truncate ml-4">{{ artifact.commit_message }}</dd>
</div>
{% endif %}
{% if artifact.repo_url %}
<div class="flex justify-between">
<dt class="text-gray-500">Repository</dt>
<dd><a href="{{ artifact.repo_url }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">View repo</a></dd>
</div>
{% endif %}
</dl>
</div>
{% endif %}
<!-- Source info -->
{% if artifact.source_user or artifact.source_type %}
<div class="p-4 border border-gray-200 rounded-lg">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Source</h3>
<dl class="space-y-2 text-sm">
{% if artifact.source_user %}
<div class="flex justify-between">
<dt class="text-gray-500">Created by</dt>
<dd class="text-gray-700">{{ artifact.source_user }}</dd>
</div>
{# Stage label #}
{% if stage.stage_type == "deploy" %}
<span class="text-sm {{ 'text-gray-700' if stage.status == 'SUCCEEDED' else 'text-yellow-700' if stage.status == 'RUNNING' else 'text-red-700' if stage.status == 'FAILED' else 'text-gray-400' }}">
{% if stage.status == "SUCCEEDED" %}Deployed to{% elif stage.status == "RUNNING" %}Deploying to{% elif stage.status == "QUEUED" %}Queued for{% elif stage.status == "FAILED" %}Failed on{% elif stage.status == "TIMED_OUT" %}Timed out on{% elif stage.status == "CANCELLED" %}Cancelled{% else %}Deploy to{% endif %}
</span>
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in stage.environment and 'preprod' not in stage.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in stage.environment or 'pre-prod' in stage.environment %}bg-orange-100 text-orange-800{% elif 'stag' in stage.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in stage.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
{{ stage.environment }}
</span>
{% elif stage.stage_type == "wait" %}
<span class="text-sm {{ 'text-gray-700' if stage.status == 'SUCCEEDED' else 'text-yellow-700' if stage.status == 'RUNNING' else 'text-gray-400' }}">
{% if stage.status == "SUCCEEDED" %}Waited{% elif stage.status == "RUNNING" %}Waiting{% elif stage.status == "FAILED" %}Wait failed{% elif stage.status == "CANCELLED" %}Wait cancelled{% else %}Wait{% endif %}
{% if stage.duration_seconds %}{{ stage.duration_seconds }}s{% endif %}
</span>
{% endif %}
{% if artifact.source_email %}
<div class="flex justify-between">
<dt class="text-gray-500">Email</dt>
<dd class="text-gray-700">{{ artifact.source_email }}</dd>
</div>
{# Elapsed time #}
{% if stage.started_at %}
<span class="text-xs text-gray-400 tabular-nums">{{ ts(stage.started_at) }}</span>
{% endif %}
{% if artifact.source_type %}
<div class="flex justify-between">
<dt class="text-gray-500">Type</dt>
<dd class="text-gray-700">{{ artifact.source_type }}</dd>
</div>
{# Error message #}
{% if stage.error_message %}
<span class="text-xs text-red-600 ml-auto">{{ stage.error_message }}</span>
{% endif %}
{% if artifact.run_url %}
<div class="flex justify-between">
<dt class="text-gray-500">CI Run</dt>
<dd><a href="{{ artifact.run_url }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">View run</a></dd>
</div>
</div>
{% endfor %}
</div>
{# Pipeline progress #}
{% set ns = namespace(done=0, total=0) %}
{% for stage in pipeline_stages %}
{% set ns.total = ns.total + 1 %}
{% if stage.status == "SUCCEEDED" %}{% set ns.done = ns.done + 1 %}{% endif %}
{% endfor %}
<p class="text-xs text-gray-400 mt-2">{{ ns.done }}/{{ ns.total }} stages complete</p>
</div>
{% elif has_pipeline %}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-900 mb-3">Pipeline</h2>
<div class="flex items-center gap-2 text-sm text-gray-500 p-4 border border-gray-200 rounded-lg">
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>Pipeline configured — waiting for release to be triggered.</span>
</div>
</div>
{% endif %}
{# ── Destinations with status ──────────────────────────────── #}
{% if destinations | length > 0 or configured_destinations | length > 0 %}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-900 mb-3">Destinations</h2>
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
{% for dest in destinations %}
<div class="px-4 py-3 flex items-center gap-3 text-sm">
{# Status icon #}
{% if dest.status == "SUCCEEDED" %}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% elif dest.status == "RUNNING" or dest.status == "ASSIGNED" %}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{% elif dest.status == "QUEUED" %}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% elif dest.status == "FAILED" %}
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% endif %}
</dl>
{# Environment badge #}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-100 text-orange-800{% elif 'stag' in dest.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in dest.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
{{ dest.environment }}
</span>
<span class="text-gray-700">{{ dest.name }}</span>
{# Status label #}
{% if dest.status == "SUCCEEDED" %}
<span class="text-xs text-green-600">Deployed</span>
{% elif dest.status == "RUNNING" %}
<span class="text-xs text-yellow-600">Deploying</span>
{% elif dest.status == "QUEUED" %}
<span class="text-xs text-blue-600">Queued{% if dest.queue_position %} #{{ dest.queue_position }}{% endif %}</span>
{% elif dest.status == "FAILED" %}
<span class="text-xs text-red-600">Failed</span>
{% elif dest.status == "TIMED_OUT" %}
<span class="text-xs text-orange-600">Timed out</span>
{% elif dest.status == "CANCELLED" %}
<span class="text-xs text-gray-500">Cancelled</span>
{% endif %}
{# Error message #}
{% if dest.error_message %}
<span class="text-xs text-red-600 truncate ml-auto max-w-xs" title="{{ dest.error_message }}">{{ dest.error_message }}</span>
{% endif %}
{# Timestamp #}
{% if dest.completed_at %}
<time class="text-xs text-gray-400 ml-auto tabular-nums">{{ ts(dest.completed_at) }}</time>
{% elif dest.started_at %}
<time class="text-xs text-gray-400 ml-auto tabular-nums">{{ ts(dest.started_at) }}</time>
{% endif %}
</div>
{% endfor %}
</div>
{# Show configured destinations that don't have live state yet #}
{% if configured_destinations | length > 0 and destinations | length == 0 %}
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
{% for cd in configured_destinations %}
<div class="px-4 py-3 flex items-center gap-3 text-sm">
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in cd.environment and 'preprod' not in cd.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in cd.environment or 'pre-prod' in cd.environment %}bg-orange-100 text-orange-800{% elif 'stag' in cd.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in cd.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
{{ cd.environment }}
</span>
<span class="text-gray-700">{{ cd.name }}</span>
<span class="text-xs text-gray-400">Not deployed</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
<!-- Links -->
{% if artifact.web or artifact.pr %}
{# ── Logs ──────────────────────────────────────────────────── #}
{% if has_release_intents %}
<div class="mb-8">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Links</h3>
<div class="flex gap-3">
{% if artifact.web %}
<a href="{{ artifact.web }}" class="inline-flex items-center px-3 py-1.5 border border-gray-200 rounded-md text-sm text-gray-700 hover:bg-gray-50" target="_blank" rel="noopener">Web</a>
<h2 class="text-sm font-semibold text-gray-900 mb-3">Logs</h2>
<release-logs url="/api/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/logs"></release-logs>
</div>
{% endif %}
{# ── Details ───────────────────────────────────────────────── #}
{% if artifact.commit_message or artifact.repo_url or artifact.source_email or artifact.run_url %}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-900 mb-3">Details</h2>
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
{% if artifact.commit_message %}
<div class="px-4 py-3 flex items-center gap-3 text-sm">
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
<span class="text-gray-700">{{ artifact.commit_message }}</span>
</div>
{% endif %}
{% if artifact.pr %}
<a href="{{ artifact.pr }}" class="inline-flex items-center px-3 py-1.5 border border-gray-200 rounded-md text-sm text-gray-700 hover:bg-gray-50" target="_blank" rel="noopener">Pull request</a>
{% if artifact.repo_url %}
<div class="px-4 py-3 flex items-center gap-3 text-sm">
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
<a href="{{ artifact.repo_url }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">{{ artifact.repo_url }}</a>
</div>
{% endif %}
{% if artifact.source_email %}
<div class="px-4 py-3 flex items-center gap-3 text-sm">
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
<span class="text-gray-500">{{ artifact.source_email }}</span>
</div>
{% endif %}
{% if artifact.run_url %}
<div class="px-4 py-3 flex items-center gap-3 text-sm">
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<a href="{{ artifact.run_url }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">View CI run</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Destinations -->
{% if artifact.destinations %}
{# ── Links ─────────────────────────────────────────────────── #}
{% if artifact.web or artifact.pr %}
<div class="mb-8">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Destinations</h3>
<div class="space-y-2">
{% for dest in artifact.destinations %}
<div class="flex items-center gap-3 p-3 border border-gray-200 rounded-lg">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">{{ dest.environment }}</span>
<span class="text-sm text-gray-900">{{ dest.name }}</span>
</div>
{% endfor %}
<h2 class="text-sm font-semibold text-gray-900 mb-3">Links</h2>
<div class="flex gap-3">
{% if artifact.web %}
<a href="{{ artifact.web }}" class="inline-flex items-center px-3 py-1.5 border border-gray-200 rounded-md text-sm text-gray-700 hover:bg-gray-50" target="_blank" rel="noopener">
<svg class="w-4 h-4 mr-1.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
Web
</a>
{% endif %}
{% if artifact.pr %}
<a href="{{ artifact.pr }}" class="inline-flex items-center px-3 py-1.5 border border-gray-200 rounded-md text-sm text-gray-700 hover:bg-gray-50" target="_blank" rel="noopener">
<svg class="w-4 h-4 mr-1.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
Pull request
</a>
{% endif %}
</div>
</div>
{% endif %}
<div class="text-sm text-gray-500">
<p>Created {{ artifact.created_at }}</p>
<p>Created {{ ts(artifact.created_at) }}</p>
</div>
</section>
<script src="/static/js/components/forage-components.js"></script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html.jinja" %}
{% from "components/timestamp.html.jinja" import timeago as ts %}
{% block content %}
<section class="max-w-2xl mx-auto px-4 py-12">
@@ -30,16 +31,25 @@
{% if recent_activity %}
<div class="space-y-2">
{% for item in recent_activity %}
<a href="/orgs/{{ item.org_name }}/projects/{{ item.project_name }}" class="block px-4 py-3 border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
<a href="/orgs/{{ item.org_name }}/projects/{{ item.project_name }}/releases/{{ item.slug }}" class="block px-4 py-3 border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="min-w-0 flex-1">
<p class="font-medium truncate">{{ item.title }}</p>
<p class="text-sm text-gray-500 mt-0.5">
{{ item.org_name }} / {{ item.project_name }}
</p>
</div>
<div class="text-right text-sm text-gray-400 shrink-0 ml-4">
<p class="font-mono text-xs">{{ item.slug }}</p>
<div class="text-right shrink-0 ml-4">
{% if item.dest_envs %}
<div class="flex gap-1 mb-1 justify-end">
{% for env in item.dest_envs %}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in env and 'preprod' not in env %}bg-pink-100 text-pink-800{% elif 'preprod' in env or 'pre-prod' in env %}bg-orange-100 text-orange-800{% elif 'stag' in env %}bg-yellow-100 text-yellow-800{% elif 'dev' in env %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
{{ env }}
</span>
{% endfor %}
</div>
{% endif %}
{{ ts(item.created_at, class="text-xs text-gray-400") }}
</div>
</div>
</a>

View File

@@ -0,0 +1,142 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-3xl mx-auto px-4 py-12">
<nav class="flex items-center gap-1.5 text-sm text-gray-500 mb-6">
<a href="/orgs/{{ org_name }}/destinations" class="hover:text-gray-900 transition-colors">Destinations</a>
<svg class="w-3.5 h-3.5" 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>
<span class="text-gray-900 font-medium">{{ dest_name }}</span>
</nav>
<div class="flex items-start justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ dest_name }}</h1>
<p class="text-sm text-gray-500 mt-1">
Environment:
<span class="inline-flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full shrink-0 {% if 'prod' in dest_environment and 'preprod' not in dest_environment %}bg-pink-500{% elif 'preprod' in dest_environment or 'pre-prod' in dest_environment %}bg-orange-500{% elif 'stag' in dest_environment %}bg-yellow-500{% elif 'dev' in dest_environment %}bg-violet-500{% else %}bg-gray-400{% endif %}"></span>
<span class="font-medium text-gray-700">{{ dest_environment }}</span>
</span>
</p>
</div>
</div>
{# Type info #}
<div class="border border-gray-200 rounded-lg overflow-hidden mb-6">
<div class="px-5 py-3 bg-gray-50">
<span class="text-sm font-medium text-gray-900">Type</span>
</div>
<div class="px-5 py-4">
{% if dest_type_name %}
<div class="flex items-center gap-4">
<div>
<span class="text-xs text-gray-500">Name</span>
<p class="text-sm font-medium text-gray-900">{{ dest_type_name }}</p>
</div>
{% if dest_type_organisation %}
<div>
<span class="text-xs text-gray-500">Organisation</span>
<p class="text-sm font-medium text-gray-900">{{ dest_type_organisation }}</p>
</div>
{% endif %}
{% if dest_type_version %}
<div>
<span class="text-xs text-gray-500">Version</span>
<p class="text-sm font-medium text-gray-900">v{{ dest_type_version }}</p>
</div>
{% endif %}
</div>
{% else %}
<p class="text-sm text-gray-400">No type assigned</p>
{% endif %}
</div>
</div>
{# Metadata section #}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="px-5 py-3 bg-gray-50 flex items-center justify-between">
<span class="text-sm font-medium text-gray-900">Metadata</span>
{% if metadata | length > 0 %}
<span class="text-xs text-gray-400">{{ metadata | length }} key{% if metadata | length != 1 %}s{% endif %}</span>
{% endif %}
</div>
{% if is_admin %}
<form method="post" action="/orgs/{{ org_name }}/destinations/detail/update?name={{ dest_name | urlencode }}" id="metadata-form">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="divide-y divide-gray-100" id="metadata-rows">
{% for entry in metadata %}
<div class="px-5 py-3 flex items-center gap-3 metadata-row">
<input type="text" name="metadata_keys" value="{{ entry.key }}"
placeholder="key"
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
<input type="text" name="metadata_values" value="{{ entry.value }}"
placeholder="value"
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
<button type="button" onclick="this.closest('.metadata-row').remove()"
class="text-gray-300 hover:text-red-500 transition-colors p-1" title="Remove row">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
{% endfor %}
{# One empty row to start #}
<div class="px-5 py-3 flex items-center gap-3 metadata-row">
<input type="text" name="metadata_keys" placeholder="key"
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
<input type="text" name="metadata_values" placeholder="value"
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
<button type="button" onclick="this.closest('.metadata-row').remove()"
class="text-gray-300 hover:text-red-500 transition-colors p-1" title="Remove row">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
<div class="px-5 py-3 border-t border-gray-100 flex items-center justify-between">
<button type="button" id="add-row-btn"
class="text-sm text-green-600 hover:text-green-700 font-medium transition-colors flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Add row
</button>
<button type="submit"
class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 transition-colors">
Save metadata
</button>
</div>
</form>
{% else %}
{# Read-only view for non-admins #}
{% if metadata | length > 0 %}
<div class="divide-y divide-gray-100">
{% for entry in metadata %}
<div class="px-5 py-3 flex items-center gap-3">
<span class="flex-1 text-sm font-mono text-gray-600">{{ entry.key }}</span>
<span class="flex-1 text-sm font-mono text-gray-900">{{ entry.value }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="px-5 py-4 text-sm text-gray-400">No metadata</div>
{% endif %}
{% endif %}
</div>
</section>
<script>
document.getElementById('add-row-btn')?.addEventListener('click', function() {
const container = document.getElementById('metadata-rows');
const row = document.createElement('div');
row.className = 'px-5 py-3 flex items-center gap-3 metadata-row';
row.innerHTML = `
<input type="text" name="metadata_keys" placeholder="key"
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
<input type="text" name="metadata_values" placeholder="value"
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
<button type="button" onclick="this.closest('.metadata-row').remove()"
class="text-gray-300 hover:text-red-500 transition-colors p-1" title="Remove row">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>`;
container.appendChild(row);
row.querySelector('input').focus();
});
</script>
{% endblock %}

View File

@@ -3,63 +3,181 @@
{% 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">Destinations</h1>
<h1 class="text-2xl font-bold">Environments &amp; Destinations</h1>
</div>
{# ── Create destination form (admin only) ─────────────────── #}
{% if is_admin %}
<div class="border border-gray-200 rounded-lg p-5 mb-6">
<h2 class="text-sm font-medium text-gray-900 mb-3">Add destination</h2>
<form method="POST" action="/orgs/{{ org_name }}/destinations" class="flex items-end gap-3 flex-wrap">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="flex-1 min-w-[140px]">
<label for="dest-name" class="block text-xs text-gray-500 mb-1">Name</label>
<input type="text" id="dest-name" name="name" required placeholder="my-service" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
</div>
<div class="min-w-[140px]">
<label for="dest-env" class="block text-xs text-gray-500 mb-1">Environment</label>
<select id="dest-env" name="environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
<option value="staging">staging</option>
<option value="preprod">preprod</option>
<option value="production">production</option>
</select>
</div>
<button type="submit" class="px-4 py-1.5 bg-gray-900 text-white text-sm rounded-md hover:bg-gray-800">Add</button>
</form>
</div>
{% endif %}
{# ── Destination list ─────────────────────────────────────── #}
{% if destinations | length > 0 %}
<div class="space-y-2">
{% for dest in destinations %}
<div class="border border-gray-200 rounded-lg px-5 py-4 flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<span class="w-2 h-2 rounded-full shrink-0 {% if dest.environment == 'production' %}bg-purple-500{% elif dest.environment == 'preprod' %}bg-orange-500{% elif dest.environment == 'staging' %}bg-yellow-500{% else %}bg-gray-400{% endif %}"></span>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ dest.name }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full {% if dest.environment == 'production' %}bg-purple-100 text-purple-800{% elif dest.environment == 'preprod' %}bg-orange-100 text-orange-800{% elif dest.environment == 'staging' %}bg-yellow-100 text-yellow-800{% else %}bg-gray-100 text-gray-600{% endif %}">{{ dest.environment }}</span>
</div>
{% if dest.artifact_title %}
<p class="text-sm text-gray-500 mt-0.5">
Last: <a href="/orgs/{{ org_name }}/projects/{{ dest.project_name }}/releases/{{ dest.artifact_slug }}" class="text-gray-700 hover:text-black underline">{{ dest.artifact_title }}</a>
in <a href="/orgs/{{ org_name }}/projects/{{ dest.project_name }}" class="text-gray-700 hover:text-black underline">{{ dest.project_name }}</a>
</p>
{% if environments | length > 0 %}
<div class="space-y-6">
{% for env in environments %}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="px-5 py-3 bg-gray-50 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full shrink-0 {% if 'prod' in env.name and 'preprod' not in env.name %}bg-pink-500{% elif 'preprod' in env.name or 'pre-prod' in env.name %}bg-orange-500{% elif 'stag' in env.name %}bg-yellow-500{% elif 'dev' in env.name %}bg-violet-500{% else %}bg-gray-400{% endif %}"></span>
<span class="font-medium text-gray-900">{{ env.name }}</span>
{% if env.description %}
<span class="text-xs text-gray-500">&mdash; {{ env.description }}</span>
{% endif %}
</div>
<span class="text-xs text-gray-400">order: {{ env.sort_order }}</span>
</div>
{% if dest.created_at %}
<span class="text-xs text-gray-400 shrink-0" title="{{ dest.created_at | datetime }}">{{ dest.created_at | timeago }}</span>
{% if env.destinations | length > 0 %}
<div class="divide-y divide-gray-100">
{% for dest in env.destinations %}
<a href="/orgs/{{ org_name }}/destinations/detail?name={{ dest.name | urlencode }}" class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors block">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg>
<span class="text-sm font-medium text-gray-900">{{ dest.name }}</span>
{% if dest.type_name %}
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-700">{{ dest.type_name }}{% if dest.type_version %} v{{ dest.type_version }}{% endif %}</span>
{% endif %}
{% if dest.metadata | length > 0 %}
<span class="text-xs text-gray-400">{{ dest.metadata | length }} key{% if dest.metadata | length != 1 %}s{% endif %}</span>
{% endif %}
</div>
<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>
</a>
{% endfor %}
</div>
{% else %}
<div class="px-5 py-3 text-sm text-gray-400">No destinations in this environment</div>
{% endif %}
{% if is_admin %}
<details class="border-t border-gray-100">
<summary class="px-5 py-3 bg-gray-50/50 text-sm text-gray-500 cursor-pointer hover:text-gray-700 select-none">Add destination to {{ env.name }}</summary>
<div class="px-5 py-4">
<form method="post" action="/orgs/{{ org_name }}/destinations/create" class="space-y-3">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="environment" value="{{ env.name }}">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Name</label>
<input type="text" name="name" placeholder="e.g. my-app-prod" required
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Type name <span class="text-gray-400">(optional)</span></label>
<input type="text" name="type_name" placeholder="e.g. kubernetes"
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Type org <span class="text-gray-400">(optional)</span></label>
<input type="text" name="type_organisation" placeholder="defaults to {{ org_name }}"
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Type version</label>
<input type="number" name="type_version" value="1" min="1"
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Metadata <span class="text-gray-400">(optional key-value pairs)</span></label>
<div class="space-y-1.5 create-meta-rows">
<div class="flex gap-2 items-center metadata-row">
<input type="text" name="metadata_keys" placeholder="key" class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500 font-mono">
<input type="text" name="metadata_values" placeholder="value" class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500 font-mono">
<button type="button" onclick="this.closest('.metadata-row').remove()"
class="text-gray-300 hover:text-red-500 transition-colors p-1" title="Remove row">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
<button type="button" onclick="addMetaRow(this.previousElementSibling)"
class="mt-1.5 text-xs text-green-600 hover:text-green-700 font-medium transition-colors flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Add row
</button>
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 transition-colors">Create destination</button>
</div>
</form>
</div>
</details>
{% endif %}
</div>
{% endfor %}
</div>
{% if orphan_destinations | length > 0 %}
<div class="mt-6 border border-gray-200 rounded-lg overflow-hidden">
<div class="px-5 py-3 bg-gray-50">
<span class="font-medium text-gray-700">Other destinations</span>
</div>
<div class="divide-y divide-gray-100">
{% for dest in orphan_destinations %}
<a href="/orgs/{{ org_name }}/destinations/detail?name={{ dest.name | urlencode }}" class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors block">
<div class="flex items-center gap-3">
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg>
<span class="text-sm text-gray-900">{{ dest.name }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-gray-100 text-gray-600">{{ dest.environment }}</span>
{% if dest.type_name %}
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-700">{{ dest.type_name }}{% if dest.type_version %} v{{ dest.type_version }}{% endif %}</span>
{% endif %}
</div>
<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>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% else %}
<div class="border border-gray-200 rounded-lg p-8 text-center text-gray-500">
<p class="font-medium text-gray-700">No destinations yet</p>
<p class="mt-1 text-sm">Destinations appear when releases are deployed with <code class="bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">forest release create --dest</code></p>
<p class="font-medium text-gray-700">No environments yet</p>
<p class="mt-1 text-sm">Create your first environment below, or use <code class="bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">forest env create</code> from the CLI.</p>
</div>
{% endif %}
{% if is_admin %}
<div class="mt-8 border border-gray-200 rounded-lg overflow-hidden">
<div class="px-5 py-3 bg-gray-50">
<span class="font-medium text-gray-900">Create environment</span>
</div>
<div class="px-5 py-4">
<form method="post" action="/orgs/{{ org_name }}/destinations/environments" class="flex flex-col gap-3 sm:flex-row sm:items-end">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="flex-1">
<label class="block text-xs font-medium text-gray-600 mb-1">Name</label>
<input type="text" name="name" placeholder="e.g. production" required
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-600 mb-1">Description <span class="text-gray-400">(optional)</span></label>
<input type="text" name="description" placeholder="e.g. Live production environment"
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
</div>
<div class="w-24">
<label class="block text-xs font-medium text-gray-600 mb-1">Order</label>
<input type="number" name="sort_order" value="0"
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
</div>
<button type="submit" class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 transition-colors">Create</button>
</form>
</div>
</div>
{% endif %}
</section>
<script>
function addMetaRow(container) {
const row = document.createElement('div');
row.className = 'flex gap-2 items-center metadata-row';
row.innerHTML = `
<input type="text" name="metadata_keys" placeholder="key" class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500 font-mono">
<input type="text" name="metadata_values" placeholder="value" class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500 font-mono">
<button type="button" onclick="this.closest('.metadata-row').remove()"
class="text-gray-300 hover:text-red-500 transition-colors p-1" title="Remove row">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>`;
container.appendChild(row);
row.querySelector('input').focus();
}
</script>
{% endblock %}

View File

@@ -5,6 +5,12 @@
<p class="text-6xl font-bold text-gray-300">{{ status }}</p>
<h1 class="mt-4 text-2xl font-bold">{{ heading }}</h1>
<p class="mt-2 text-gray-600">{{ message }}</p>
{% if detail %}
<details class="mt-6 text-left border border-gray-200 rounded-lg">
<summary class="px-4 py-2 text-sm text-gray-500 cursor-pointer hover:text-gray-700">Error details</summary>
<pre class="px-4 py-3 text-xs text-red-700 bg-gray-50 overflow-x-auto whitespace-pre-wrap break-words border-t border-gray-200">{{ detail }}</pre>
</details>
{% endif %}
<a href="/" class="inline-block mt-8 text-sm text-gray-500 hover:text-gray-700">&larr; Back to home</a>
</section>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html.jinja" %}
{% from "components/timestamp.html.jinja" import timeago as ts %}
{% block content %}
<section class="max-w-4xl mx-auto px-4 pt-12">
@@ -57,7 +58,7 @@
{{ member.role }}
</span>
</td>
<td class="px-4 py-3 text-gray-500">{{ member.joined_at or "—" }}</td>
<td class="px-4 py-3 text-gray-500">{% if member.joined_at %}{{ ts(member.joined_at) }}{% else %}—{% endif %}</td>
{% if is_admin %}
<td class="px-4 py-3 text-right">
{% if member.role != 'owner' %}

View File

@@ -0,0 +1,65 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-4xl mx-auto px-4 pt-12 pb-12">
<h1 class="text-2xl font-bold mb-8">Notifications</h1>
<div id="notifications-list">
{% include "components/notifications_list.html.jinja" %}
</div>
</section>
<script>
(function() {
const container = document.getElementById("notifications-list");
if (!container) return;
// Track which release slugs the user has manually toggled open,
// so we don't slam them shut on the next poll.
const userToggled = new Set();
container.addEventListener("toggle", function(e) {
const details = e.target;
if (details.tagName !== "DETAILS") return;
const slug = details.dataset.slug;
if (!slug) return;
if (details.open) {
userToggled.add(slug);
} else {
userToggled.delete(slug);
}
}, true);
async function refresh() {
try {
const res = await fetch("/notifications?_partial=1", { credentials: "same-origin" });
if (!res.ok) return;
const html = await res.text();
container.innerHTML = html;
// Re-open any releases the user manually toggled open.
userToggled.forEach(function(slug) {
const el = container.querySelector('details[data-slug="' + slug + '"]');
if (el) el.open = true;
});
// Clean up slugs no longer in the DOM.
userToggled.forEach(function(slug) {
if (!container.querySelector('details[data-slug="' + slug + '"]')) {
userToggled.delete(slug);
}
});
} catch {}
}
let timer = setInterval(refresh, 10000);
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
clearInterval(timer);
timer = null;
} else {
// Immediate refresh on tab focus, then resume interval.
refresh();
timer = setInterval(refresh, 10000);
}
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,125 @@
{% extends "base.html.jinja" %}
{% from "components/timestamp.html.jinja" import timeago as ts %}
{% block content %}
<section class="max-w-5xl mx-auto px-4 pt-12">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold">Release Pipelines</h1>
<p class="text-sm text-gray-500 mt-1">
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}" class="hover:underline">{{ current_project }}</a>
&middot; Define multi-stage deployment pipelines as a DAG
</p>
</div>
</div>
{% if pipelines | length > 0 %}
<div class="space-y-3 mb-8">
{% for pipeline in pipelines %}
<details class="border border-gray-200 rounded-lg overflow-hidden group">
<summary class="px-4 py-3 flex items-center gap-3 flex-wrap cursor-pointer list-none hover:bg-gray-50">
<div class="flex items-center gap-2 min-w-0 flex-1">
{% if pipeline.enabled %}
<span class="inline-block w-2.5 h-2.5 rounded-full bg-green-500 shrink-0" title="Enabled"></span>
{% else %}
<span class="inline-block w-2.5 h-2.5 rounded-full bg-gray-300 shrink-0" title="Disabled"></span>
{% endif %}
<span class="font-medium text-gray-900">{{ pipeline.name }}</span>
</div>
<div class="flex items-center gap-3 text-xs text-gray-500 shrink-0">
{% if pipeline.stages_json %}
<span class="font-mono">{{ pipeline.stage_count }} stage{{ "s" if pipeline.stage_count != 1 }}</span>
{% else %}
<span class="italic text-gray-400">no stages</span>
{% endif %}
{{ ts(pipeline.updated_at) }}
</div>
{% if is_admin %}
<div class="flex items-center gap-2 shrink-0" onclick="event.stopPropagation()">
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/pipelines/{{ pipeline.name }}/toggle">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
{% if pipeline.enabled %}
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-50">Disable</button>
{% else %}
<input type="hidden" name="enabled" value="true">
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-green-300 text-green-700 hover:bg-green-50">Enable</button>
{% endif %}
</form>
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/pipelines/{{ pipeline.name }}/delete" onsubmit="return confirm('Delete pipeline &quot;{{ pipeline.name }}&quot;?')">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-red-300 text-red-600 hover:bg-red-50">Delete</button>
</form>
</div>
{% endif %}
<svg class="w-4 h-4 text-gray-400 shrink-0 transition-transform group-open:rotate-90" 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>
</summary>
<div class="border-t border-gray-100 px-4 py-3">
{% if pipeline.stages_json %}
{% if is_admin %}
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/pipelines/{{ pipeline.name }}/update" class="space-y-3">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<pipeline-builder data-target="edit-{{ pipeline.name }}"></pipeline-builder>
<textarea id="edit-{{ pipeline.name }}" name="stages_json" hidden>{{ pipeline.stages_json }}</textarea>
<button type="submit" class="text-xs px-3 py-1.5 rounded bg-gray-900 text-white hover:bg-gray-800">Save Changes</button>
</form>
{% else %}
<pipeline-builder data-target="view-{{ pipeline.name }}" data-readonly="true"></pipeline-builder>
<textarea id="view-{{ pipeline.name }}" hidden>{{ pipeline.stages_json }}</textarea>
{% endif %}
{% else %}
{% if is_admin %}
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/pipelines/{{ pipeline.name }}/update" class="space-y-3">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<pipeline-builder data-target="edit-{{ pipeline.name }}"></pipeline-builder>
<textarea id="edit-{{ pipeline.name }}" name="stages_json" hidden></textarea>
<button type="submit" class="text-xs px-3 py-1.5 rounded bg-gray-900 text-white hover:bg-gray-800">Save Changes</button>
</form>
{% else %}
<p class="text-sm text-gray-400 italic">No stages configured yet.</p>
{% endif %}
{% endif %}
</div>
</details>
{% endfor %}
</div>
{% else %}
<div class="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-500 mb-8">
<p class="mb-1">No release pipelines configured.</p>
{% if is_admin %}
<p class="text-sm">Create one below to define a multi-stage deployment DAG.</p>
{% endif %}
</div>
{% endif %}
{% if is_admin %}
<div class="border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">Create Pipeline</h2>
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/pipelines" class="space-y-4">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div>
<label for="pipeline-name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" id="pipeline-name" name="name" required placeholder="e.g. deploy-to-production"
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
<p class="text-xs text-gray-500 mt-1">A unique identifier for this pipeline.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Stages</label>
<pipeline-builder data-target="pipeline-stages"></pipeline-builder>
<textarea id="pipeline-stages" name="stages_json" hidden></textarea>
</div>
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
Create Pipeline
</button>
</form>
</div>
{% endif %}
</section>
<script src="/static/js/pipeline-builder.js"></script>
{% endblock %}

View File

@@ -0,0 +1,175 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-5xl mx-auto px-4 pt-12">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold">Deployment Policies</h1>
<p class="text-sm text-gray-500 mt-1">
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}" class="hover:underline">{{ current_project }}</a>
&middot; Gate deployments with soak times and branch restrictions
</p>
</div>
</div>
{% if policies | length > 0 %}
<div class="space-y-3 mb-8">
{% for policy in policies %}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0 flex-1">
{% if policy.enabled %}
<span class="inline-block w-2.5 h-2.5 rounded-full bg-green-500 shrink-0" title="Enabled"></span>
{% else %}
<span class="inline-block w-2.5 h-2.5 rounded-full bg-gray-300 shrink-0" title="Disabled"></span>
{% endif %}
<span class="font-medium text-gray-900">{{ policy.name }}</span>
</div>
<div class="flex items-center gap-1.5 text-xs text-gray-500 flex-wrap">
{% if policy.policy_type == "soak_time" %}
<span class="bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded">Soak Time</span>
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.source_environment }}</code>
<span class="text-gray-300">&#8594;</span>
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.target_environment }}</code>
<span class="text-gray-400">&middot;</span>
<span>{{ policy.config.duration_human }}</span>
{% elif policy.policy_type == "branch_restriction" %}
<span class="bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded">Branch Restriction</span>
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.branch_pattern }}</code>
<span class="text-gray-300">&#8594;</span>
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.target_environment }}</code>
{% endif %}
</div>
{% if is_admin %}
<div class="flex items-center gap-2 shrink-0">
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}/policies/{{ policy.name }}" class="text-xs px-2.5 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-50">Edit</a>
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/policies/{{ policy.name }}/toggle">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% if policy.enabled %}
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-50">Disable</button>
{% else %}
<input type="hidden" name="enabled" value="true">
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-green-300 text-green-700 hover:bg-green-50">Enable</button>
{% endif %}
</form>
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/policies/{{ policy.name }}/delete" onsubmit="return confirm('Delete policy &quot;{{ policy.name }}&quot;?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-red-300 text-red-600 hover:bg-red-50">Delete</button>
</form>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-500 mb-8">
<p class="mb-1">No deployment policies configured.</p>
{% if is_admin %}
<p class="text-sm">Create one below to gate deployments with soak times or branch restrictions.</p>
{% endif %}
</div>
{% endif %}
{% if is_admin %}
<div class="border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">Create Policy</h2>
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/policies" class="space-y-4" id="policy-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div>
<label for="policy-name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" id="policy-name" name="name" required placeholder="e.g. staging-soak-30m"
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
</div>
<div>
<label for="policy-type" class="block text-sm font-medium text-gray-700 mb-1">Type</label>
<select id="policy-type" name="policy_type"
class="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-gray-900">
<option value="soak_time">Soak Time</option>
<option value="branch_restriction">Branch Restriction</option>
</select>
<p class="text-xs text-gray-500 mt-1" id="policy-type-desc">
Require an artifact to succeed in a source environment for a duration before deploying to target.
</p>
</div>
{# Soak Time fields #}
<div id="soak-time-fields">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Source environment</label>
<select name="source_environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
{% for env in environments %}
<option value="{{ env.name }}">{{ env.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
<select name="target_environment" id="soak-target-env" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
{% for env in environments %}
<option value="{{ env.name }}">{{ env.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="mt-3">
<label class="block text-sm font-medium text-gray-700 mb-1">Soak duration (seconds)</label>
<input type="number" name="duration_seconds" min="1" placeholder="1800"
class="w-48 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
<p class="text-xs text-gray-500 mt-1">e.g. 1800 = 30 minutes, 3600 = 1 hour</p>
</div>
</div>
{# Branch Restriction fields #}
<div id="branch-restriction-fields" class="hidden">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Branch pattern</label>
<input type="text" name="branch_pattern" placeholder="e.g. main"
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
<select name="target_environment" id="branch-target-env" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
{% for env in environments %}
<option value="{{ env.name }}">{{ env.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
Create Policy
</button>
</form>
</div>
<script>
(function() {
const typeSelect = document.getElementById('policy-type');
const soakFields = document.getElementById('soak-time-fields');
const branchFields = document.getElementById('branch-restriction-fields');
const desc = document.getElementById('policy-type-desc');
const descriptions = {
soak_time: 'Require an artifact to succeed in a source environment for a duration before deploying to target.',
branch_restriction: 'Only allow deployments to the target environment from a specific branch pattern.',
};
typeSelect.addEventListener('change', () => {
const isSoak = typeSelect.value === 'soak_time';
soakFields.classList.toggle('hidden', !isSoak);
branchFields.classList.toggle('hidden', isSoak);
desc.textContent = descriptions[typeSelect.value] || '';
});
})();
</script>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-5xl mx-auto px-4 pt-12">
<div class="mb-8">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}/policies" class="hover:underline">Policies</a>
<span>&rsaquo;</span>
<span>{{ policy.name }}</span>
</div>
<h1 class="text-2xl font-bold">Edit Policy</h1>
</div>
<div class="border border-gray-200 rounded-lg p-6">
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/policies/{{ policy.name }}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="policy_type" value="{{ policy.policy_type }}">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
<span>Type:</span>
{% if policy.policy_type == "soak_time" %}
<span class="bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded text-xs font-medium">Soak Time</span>
{% elif policy.policy_type == "branch_restriction" %}
<span class="bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded text-xs font-medium">Branch Restriction</span>
{% endif %}
</div>
{% if policy.policy_type == "soak_time" %}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Source environment</label>
<select name="source_environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
{% for env in environments %}
<option value="{{ env.name }}" {% if env.name == policy.config.source_environment %}selected{% endif %}>{{ env.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
<select name="target_environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
{% for env in environments %}
<option value="{{ env.name }}" {% if env.name == policy.config.target_environment %}selected{% endif %}>{{ env.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Soak duration (seconds)</label>
<input type="number" name="duration_seconds" min="1" value="{{ policy.config.duration_seconds }}"
class="w-48 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
<p class="text-xs text-gray-500 mt-1">e.g. 1800 = 30 minutes, 3600 = 1 hour</p>
</div>
{% elif policy.policy_type == "branch_restriction" %}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Branch pattern</label>
<input type="text" name="branch_pattern" value="{{ policy.config.branch_pattern }}"
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
<select name="target_environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
{% for env in environments %}
<option value="{{ env.name }}" {% if env.name == policy.config.target_environment %}selected{% endif %}>{{ env.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
Save Changes
</button>
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}/policies" class="text-sm text-gray-500 hover:text-gray-900">Cancel</a>
</div>
</form>
</div>
</section>
{% endblock %}

View File

@@ -1,65 +1,26 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-4xl mx-auto px-4 pt-12">
<div class="flex items-center justify-between mb-8">
<div>
<a href="/orgs/{{ org_name }}/projects" class="text-sm text-gray-500 hover:text-gray-700">&larr; {{ org_name }}</a>
<h1 class="text-2xl font-bold mt-1">{{ project_name }}</h1>
<section class="px-4 pt-12">
<div class="max-w-5xl mx-auto flex items-center justify-between mb-8">
<h1 class="text-2xl font-bold">Continuous deployment</h1>
<div class="flex items-center gap-4">
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/pipelines" class="text-sm text-gray-500 hover:text-gray-900 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16"/></svg>
Pipelines
</a>
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/triggers" class="text-sm text-gray-500 hover:text-gray-900 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
Triggers
</a>
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/policies" class="text-sm text-gray-500 hover:text-gray-900 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
Policies
</a>
</div>
</div>
<h2 class="font-bold text-lg mb-4">Recent releases</h2>
{% if artifacts %}
<div class="space-y-4">
{% for artifact in artifacts %}
<div class="p-4 border border-gray-200 rounded-lg">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}" class="font-medium text-gray-900 hover:text-blue-600">{{ artifact.title }}</a>
{% if artifact.version %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">{{ artifact.version }}</span>
{% endif %}
</div>
{% if artifact.description %}
<p class="text-sm text-gray-600 mt-1">{{ artifact.description }}</p>
{% endif %}
{% if artifact.branch or artifact.commit_sha %}
<div class="flex items-center gap-2 mt-2 text-xs text-gray-500">
{% if artifact.branch %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 font-mono">{{ artifact.branch }}</span>
{% endif %}
{% if artifact.commit_sha %}
<span class="font-mono">{{ artifact.commit_sha[:8] }}</span>
{% endif %}
</div>
{% endif %}
{% if artifact.source_user %}
<p class="text-xs text-gray-400 mt-1">by {{ artifact.source_user }}{% if artifact.source_type %} via {{ artifact.source_type }}{% endif %}</p>
{% endif %}
{% if artifact.destinations %}
<div class="flex gap-1.5 mt-2">
{% for dest in artifact.destinations %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">{{ dest.name }} ({{ dest.environment }})</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="text-right text-sm text-gray-500 shrink-0 ml-4">
<p class="font-mono">{{ artifact.slug }}</p>
<p>{{ artifact.created_at }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-6 border border-gray-200 rounded-lg text-center">
<p class="text-gray-600">No releases yet.</p>
<p class="text-sm text-gray-400 mt-2">Create a release with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
</div>
{% endif %}
<release-timeline org="{{ org_name }}" project="{{ project_name }}"></release-timeline>
</section>
<script src="/static/js/components/forage-components.js"></script>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{% extends "base.html.jinja" %}
{% from "components/timestamp.html.jinja" import timeago as ts %}
{% block content %}
<section class="max-w-4xl mx-auto px-4 pt-12">
<h1 class="text-2xl font-bold mb-8">Releases</h1>
{% if releases %}
<div class="border border-gray-200 rounded-lg divide-y divide-gray-200">
{% for release in releases %}
<div class="px-5 py-4 hover:bg-gray-50">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ release.slug }}" class="font-medium text-gray-900 truncate hover:underline">{{ release.title }}</a>
{% if release.status == "deployed" %}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-green-100 text-green-800">
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
Deployed
</span>
{% else %}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
<span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
Pending
</span>
{% endif %}
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-1.5">
{% if release.branch %}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-blue-50 text-blue-700 border border-blue-200">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
{{ release.branch }}
</span>
{% endif %}
{% if release.commit_sha %}
<span class="inline-flex items-center gap-1 text-xs font-mono px-2 py-0.5 rounded-full bg-gray-50 text-gray-600 border border-gray-200">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
{{ release.commit_sha[:7] }}
</span>
{% endif %}
{% if release.version %}
<span class="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200">
{{ release.version }}
</span>
{% endif %}
{% if release.source_type %}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-purple-50 text-purple-700 border border-purple-200">
{% if 'github' in release.source_type %}
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
{% elif 'gitlab' in release.source_type %}
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 01-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 014.82 2a.43.43 0 01.58 0 .42.42 0 01.11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0118.6 2a.43.43 0 01.58 0 .42.42 0 01.11.18l2.44 7.51L23 13.45a.84.84 0 01-.35.94z"/></svg>
{% else %}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
{% endif %}
{{ release.source_type | replace("_", " ") | replace("-", " ") }}
</span>
{% endif %}
{% if release.source_user %}
<a href="/users/{{ release.source_user }}" class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 transition-colors">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
{{ release.source_user }}
</a>
{% endif %}
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
{% if release.envs %}
<div class="flex gap-1">
{% for env in release.envs %}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in env and 'preprod' not in env %}bg-pink-100 text-pink-800{% elif 'preprod' in env or 'pre-prod' in env %}bg-orange-100 text-orange-800{% elif 'stag' in env %}bg-yellow-100 text-yellow-800{% elif 'dev' in env %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
{{ env }}
</span>
{% endfor %}
</div>
{% endif %}
{{ ts(release.created_at, class="text-xs text-gray-400") }}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-6 border border-gray-200 rounded-lg text-center">
<p class="text-gray-600">No releases yet.</p>
<p class="text-sm text-gray-400 mt-2">Create a release with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,152 +1,12 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-5xl mx-auto px-4 py-12">
<div class="flex items-center justify-between mb-8">
<section class="px-4 py-12">
<div class="max-w-5xl mx-auto flex items-center justify-between mb-8">
<h1 class="text-2xl font-bold">Continuous deployment</h1>
</div>
{% if timeline | length > 0 %}
<swim-lanes>
{# ── Environment swim lanes ──────────────────────────────── #}
{% for lane in lanes %}
<div data-lane="{{ lane.name }}"></div>
{% endfor %}
{# ── Release timeline ─────────────────────────────────────── #}
<div data-swimlane-timeline class="flex-1 space-y-3 min-w-0 ml-2">
{% for item in timeline %}
{% if item.kind == "release" %}
{# ── Visible release card ──────────────────────────────── #}
{% set release = item.release %}
<div data-release data-envs="{{ release.dest_envs }}" class="border border-gray-200 rounded-lg overflow-hidden">
{# Release header #}
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="inline-block w-6 h-6 rounded-full bg-gray-200 shrink-0" data-avatar></span>
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}/releases/{{ release.slug }}" class="font-medium text-gray-900 hover:text-black truncate">
{{ release.title }}
</a>
</div>
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0 flex-wrap">
{% if release.branch %}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z"/></svg>
{{ release.branch }}
</span>
{% endif %}
{% if release.commit_sha %}
<span class="font-mono">{{ release.commit_sha[:7] }}</span>
{% endif %}
<span title="{{ release.created_at | datetime }}">{{ release.created_at | timeago }}</span>
{% if release.source_user %}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
{{ release.source_user }}
</span>
{% endif %}
<span class="text-gray-400">
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}" class="hover:text-gray-700">{{ release.project_name }}</a>
</span>
</div>
</div>
{# Deployment steps (collapsed by default) #}
<details class="border-t border-gray-100 group">
<summary class="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer list-none hover:bg-gray-50">
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-600 text-sm">Deployed to</span>
{% for dest in release.destinations %}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-100 text-orange-800{% elif 'stag' in dest.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in dest.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
{{ dest.environment }}
<span class="w-1.5 h-1.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-500{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-500{% elif 'stag' in dest.environment %}bg-yellow-500{% elif 'dev' in dest.environment %}bg-violet-500{% else %}bg-gray-400{% endif %}"></span>
</span>
{% endfor %}
<svg class="w-3 h-3 text-gray-400 shrink-0 ml-auto transition-transform group-open:rotate-90" 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>
</summary>
{% for dest in release.destinations %}
<div class="px-4 py-2 flex items-center gap-3 text-sm {% if not loop.last %}border-b border-gray-50{% endif %} border-t border-gray-50">
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-600">Deployed to</span>
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-100 text-orange-800{% elif 'stag' in dest.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in dest.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
{{ dest.environment }}
<span class="w-1.5 h-1.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-500{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-500{% elif 'stag' in dest.environment %}bg-yellow-500{% elif 'dev' in dest.environment %}bg-violet-500{% else %}bg-gray-400{% endif %}"></span>
</span>
<span class="text-gray-400 text-xs">{{ dest.name }}</span>
{% if dest.type_name %}
<span class="text-gray-400 text-xs">({{ dest.type_name }}{% if dest.type_version %} v{{ dest.type_version }}{% endif %})</span>
{% endif %}
</div>
{% endfor %}
</details>
</div>
{% elif item.kind == "hidden" %}
{# ── Hidden commits group ──────────────────────────────── #}
<details class="group">
<summary class="flex items-center gap-2 py-2 px-1 text-sm text-gray-400 cursor-pointer hover:text-gray-600 list-none">
<svg class="w-3 h-3 transition-transform group-open:rotate-90" 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>
{{ item.count }} hidden commit{{ "s" if item.count != 1 }}
<span class="text-gray-300">&middot;</span>
<span class="group-open:hidden">Show commit{{ "s" if item.count != 1 }}</span>
<span class="hidden group-open:inline">Hide commit{{ "s" if item.count != 1 }}</span>
</summary>
<div class="space-y-3 mt-1">
{% for release in item.releases %}
<div data-release data-envs="" class="border border-gray-200 rounded-lg overflow-hidden opacity-75">
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="inline-block w-6 h-6 rounded-full bg-gray-200 shrink-0" data-avatar></span>
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}/releases/{{ release.slug }}" class="font-medium text-gray-900 hover:text-black truncate">
{{ release.title }}
</a>
</div>
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0 flex-wrap">
{% if release.commit_sha %}
<span class="font-mono">{{ release.commit_sha[:7] }}</span>
{% endif %}
<span title="{{ release.created_at | datetime }}">{{ release.created_at | timeago }}</span>
{% if release.source_user %}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
{{ release.source_user }}
</span>
{% endif %}
<span class="text-gray-400">
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}" class="hover:text-gray-700">{{ release.project_name }}</a>
</span>
</div>
</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% endfor %}
</div>
</swim-lanes>
{% else %}
{# ── Empty state ──────────────────────────────────────────── #}
<div class="border border-gray-200 rounded-lg p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/></svg>
</div>
<p class="font-medium text-gray-900 mb-1">No releases yet</p>
<p class="text-sm text-gray-500 mb-6">Releases appear when you deploy with Forest CLI.</p>
<div class="bg-gray-50 rounded-lg p-4 text-left">
<p class="text-xs font-medium text-gray-700 mb-2">Get started with the CLI:</p>
<pre class="text-xs text-gray-600 overflow-x-auto"><code>forest release create \
--org {{ org_name }} \
--project my-project \
--dest staging:my-service</code></pre>
</div>
</div>
</div>
{% endif %}
<release-timeline org="{{ org_name }}"></release-timeline>
</section>
<script src="/static/js/swim-lanes.js"></script>
<script src="/static/js/components/forage-components.js"></script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html.jinja" %}
{% from "components/timestamp.html.jinja" import timeago as ts %}
{% block content %}
<section class="max-w-4xl mx-auto px-4 pt-12">
@@ -50,9 +51,9 @@
{% for token in tokens %}
<tr>
<td class="px-6 py-3 font-medium">{{ token.name }}</td>
<td class="px-6 py-3 text-gray-600">{{ token.created_at or "—" }}</td>
<td class="px-6 py-3 text-gray-600">{{ token.last_used or "Never" }}</td>
<td class="px-6 py-3 text-gray-600">{{ token.expires_at or "Never" }}</td>
<td class="px-6 py-3 text-gray-600">{% if token.created_at %}{{ ts(token.created_at) }}{% else %}—{% endif %}</td>
<td class="px-6 py-3 text-gray-600">{% if token.last_used %}{{ ts(token.last_used) }}{% else %}Never{% endif %}</td>
<td class="px-6 py-3 text-gray-600">{% if token.expires_at %}{{ ts(token.expires_at) }}{% else %}Never{% endif %}</td>
<td class="px-6 py-3 text-right">
<form method="POST" action="/settings/tokens/{{ token.token_id }}/delete">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">

Some files were not shown because too many files have changed in this diff Show More