1
.playwright-mcp/console-2026-03-07T23-04-16-205Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-07T23-06-48-917Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-07T23-08-47-266Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-07T23-28-21-144Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-08T13-09-32-749Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-08T14-22-14-670Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-08T14-24-37-198Z.log
Normal 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
|
||||
10
.playwright-mcp/console-2026-03-08T15-10-54-843Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-08T18-58-10-322Z.log
Normal 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
|
||||
4
.playwright-mcp/console-2026-03-08T19-03-18-217Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-08T19-16-00-412Z.log
Normal 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
|
||||
10
.playwright-mcp/console-2026-03-08T20-27-09-753Z.log
Normal 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
|
||||
5
.playwright-mcp/console-2026-03-08T21-01-30-414Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-08T21-05-06-279Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 1030036ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/notifications?_partial=1:0
|
||||
BIN
.playwright-mcp/element-2026-03-08T21-50-01-232Z.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
.playwright-mcp/element-2026-03-08T21-50-44-447Z.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
.playwright-mcp/element-2026-03-08T21-53-14-160Z.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
.playwright-mcp/element-2026-03-08T21-54-05-889Z.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
.playwright-mcp/element-2026-03-08T21-55-10-800Z.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
.playwright-mcp/page-2026-03-08T21-56-48-888Z.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
187
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
|
||||
312
crates/forage-server/src/routes/events.rs
Normal 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(())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 47 KiB |
1405
frontend/package-lock.json
generated
Normal file
14
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
469
frontend/src/ReleaseLogs.svelte
Normal 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>
|
||||
670
frontend/src/ReleaseTimeline.svelte
Normal 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">·</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
@@ -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`;
|
||||
}
|
||||
53
frontend/src/lib/colors.js
Normal 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;
|
||||
}
|
||||
}
|
||||
63
frontend/src/lib/status.js
Normal 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
@@ -0,0 +1,3 @@
|
||||
// Register all Svelte web components
|
||||
import "./ReleaseTimeline.svelte";
|
||||
import "./ReleaseLogs.svelte";
|
||||
27
frontend/vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
79
interface/proto/forest/v1/auto_release_policies.proto
Normal 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);
|
||||
}
|
||||
57
interface/proto/forest/v1/destinations.proto
Normal 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;
|
||||
}
|
||||
67
interface/proto/forest/v1/environments.proto
Normal 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);
|
||||
}
|
||||
100
interface/proto/forest/v1/release_pipelines.proto
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 53 KiB |
BIN
nav-members.png
|
Before Width: | Height: | Size: 54 KiB |
BIN
nav-projects.png
|
Before Width: | Height: | Size: 42 KiB |
BIN
nav-pt3.png
|
Before Width: | Height: | Size: 53 KiB |
BIN
nav-pt5.png
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 75 KiB |
BIN
nav-usage.png
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 87 KiB |
BIN
releases-v2.png
|
Before Width: | Height: | Size: 101 KiB |
BIN
releases-v3.png
|
Before Width: | Height: | Size: 100 KiB |
BIN
releases-v4.png
|
Before Width: | Height: | Size: 101 KiB |
BIN
releases-v5.png
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 99 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
1332
static/css/style.css
19
static/js/components/forage-components.js
Normal file
38
static/js/details-persist.js
Normal 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
@@ -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();
|
||||
})();
|
||||
629
static/js/pipeline-builder.js
Normal 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);
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
235
templates/components/notifications_list.html.jinja
Normal 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 %}
|
||||
9
templates/components/timestamp.html.jinja
Normal 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 %}
|
||||
@@ -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">← {{ 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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
142
templates/pages/destination_detail.html.jinja
Normal 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 %}
|
||||
@@ -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 & 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">— {{ 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 %}
|
||||
|
||||
@@ -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">← Back to home</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
65
templates/pages/notifications.html.jinja
Normal 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 %}
|
||||
125
templates/pages/pipelines.html.jinja
Normal 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>
|
||||
· 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 "{{ pipeline.name }}"?')">
|
||||
<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 %}
|
||||
175
templates/pages/policies.html.jinja
Normal 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>
|
||||
· 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">→</span>
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.target_environment }}</code>
|
||||
<span class="text-gray-400">·</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">→</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 "{{ policy.name }}"?')">
|
||||
<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 %}
|
||||
81
templates/pages/policy_edit.html.jinja
Normal 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>›</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 %}
|
||||
@@ -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">← {{ 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 %}
|
||||
|
||||
89
templates/pages/project_releases.html.jinja
Normal 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 %}
|
||||
@@ -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">·</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 %}
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||