feat: add plan step

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-15 22:38:18 +01:00
parent 7eb6ae7cbb
commit 04e452ecc3
71 changed files with 1059 additions and 319 deletions

View File

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

View File

@@ -1,12 +0,0 @@
[ 469877ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
[ 473324ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
[ 473751ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
[ 473934ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
[ 474119ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
[ 474291ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
[ 474467ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
[ 474629ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0
[ 560213ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0
[ 561436ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0
[ 561803ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0
[ 561970ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0

View File

@@ -1 +0,0 @@
[ 157ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://rawpotion.io/favicon.ico:0

View File

@@ -1,4 +0,0 @@
[ 6ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0
[ 1711ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0
[ 2177ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0
[ 2346ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
[ 7ms] [ERROR] Failed to load resource: the server responded with a status of 503 (Service Unavailable) @ http://localhost:3000/orgs/rawpotion/settings/integrations:0

View File

@@ -1 +0,0 @@
[ 15272ms] [ERROR] Pattern attribute value [a-z0-9][a-z0-9-]*[a-z0-9] is not a valid regular expression: Uncaught SyntaxError: Invalid regular expression: /[a-z0-9][a-z0-9-]*[a-z0-9]/v: Invalid character class @ http://localhost:3000/dashboard:0

View File

@@ -1 +0,0 @@
[ 227ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://client.dev.forage.sh/favicon.ico:0

View File

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

View File

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

View File

@@ -1 +0,0 @@
[ 314ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://client.dev.forage.sh/favicon.ico:0

View File

@@ -1 +0,0 @@
[ 61ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ chrome-error://chromewebdata/:0

View File

@@ -1,29 +0,0 @@
[ 4219ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 73166ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 74210ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 76255ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 80299ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 121662ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 122707ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 124752ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 132300ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 133345ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 135391ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 163462ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 165512ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 169559ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 206249ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 208295ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 229228ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 231273ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 240041ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 242090ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 246135ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 254184ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 270230ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 300275ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 335152ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 337223ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 341292ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 349341ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 365388ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0

View File

@@ -1 +0,0 @@
[ 80ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example:0

View File

@@ -1,70 +0,0 @@
[ 28066ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 29193ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 31313ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 36650ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 38721ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 180246ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 181291ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 183336ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 187382ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 195427ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 211472ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 308815ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 309884ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 311956ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 316026ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 324096ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 400986ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 411491ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 445156ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 649304ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 659749ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 675977ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 678046ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 754725ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 755871ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 757999ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 763330ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 765397ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 978143ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 980214ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 984281ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1080173ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1082246ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1086330ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1121684ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1123773ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1141045ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1143115ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1147188ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1155258ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1502223ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1504292ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1513751ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1515821ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1519891ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1527937ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1543982ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1574052ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1654209ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1655305ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1657399ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1661443ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1775829ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1776875ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1778919ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1783494ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1784539ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1786584ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1821582ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1822627ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1824676ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1861759ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1864040ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1865688ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1867222ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1868813ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1870286ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1871945ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1873590ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 1875131ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0

View File

@@ -1,7 +0,0 @@
[ 8921ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 93203ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 94247ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 96291ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 124526ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 126598ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 130667ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0

View File

@@ -1,6 +0,0 @@
[ 9418ms] [ERROR] Failed to load resource: net::ERR_HTTP2_PROTOCOL_ERROR @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 10488ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 12557ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 16626ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 24672ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0
[ 40718ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example/events:0

View File

@@ -1 +0,0 @@
[ 53ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ chrome-error://chromewebdata/:0

View File

@@ -1 +0,0 @@
[ 55ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://client.dev.forage.sh/orgs/rawpotion/projects/service-example:0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -520,6 +520,7 @@ pub fn format_pipeline_blocks(
"RUNNING" => ":arrows_counterclockwise:",
"FAILED" => ":x:",
"CANCELLED" => ":no_entry_sign:",
"AWAITING_APPROVAL" => ":shield:",
_ => ":radio_button:", // PENDING
};
@@ -546,6 +547,16 @@ pub fn format_pipeline_blocks(
_ => format!("Wait {dur_str}"),
}
}
"plan" => {
let env = stage.environment.as_deref().unwrap_or("unknown");
match stage.status.as_str() {
"SUCCEEDED" => format!("Plan approved for `{env}`"),
"RUNNING" => format!("Planning `{env}`"),
"AWAITING_APPROVAL" => format!("Awaiting plan approval for `{env}`"),
"FAILED" => format!("Plan failed for `{env}`"),
_ => format!("Plan `{env}`"),
}
}
_ => format!("Stage {}", stage.stage_id),
};
@@ -928,6 +939,8 @@ mod tests {
error_message: None,
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
},
PipelineRunStageState {
stage_id: "s2".into(),
@@ -942,6 +955,8 @@ mod tests {
error_message: None,
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
},
PipelineRunStageState {
stage_id: "s3".into(),
@@ -956,6 +971,8 @@ mod tests {
error_message: None,
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
},
PipelineRunStageState {
stage_id: "s4".into(),
@@ -970,6 +987,8 @@ mod tests {
error_message: None,
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
},
PipelineRunStageState {
stage_id: "s5".into(),
@@ -984,6 +1003,8 @@ mod tests {
error_message: None,
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
},
];
@@ -1024,6 +1045,8 @@ mod tests {
error_message: Some("OOM killed".into()),
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
}];
let blocks = format_pipeline_blocks(&stages);

View File

@@ -130,8 +130,8 @@ pub struct DestinationState {
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 stage_type: String, // "deploy", "wait", or "plan"
pub status: String, // "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AWAITING_APPROVAL"
pub environment: Option<String>,
pub duration_seconds: Option<i64>,
pub queued_at: Option<String>,
@@ -141,6 +141,10 @@ pub struct PipelineRunStageState {
pub wait_until: Option<String>,
#[serde(default)]
pub release_ids: Vec<String>,
#[serde(default)]
pub approval_status: Option<String>,
#[serde(default)]
pub auto_approve: Option<bool>,
}
/// Combined response from get_destination_states: destinations only.
@@ -302,6 +306,7 @@ pub struct PipelineStage {
pub enum PipelineStageConfig {
Deploy { environment: String },
Wait { duration_seconds: i64 },
Plan { environment: String, auto_approve: bool },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -628,6 +633,43 @@ pub trait ForestPlatform: Send + Sync {
release_intent_id: &str,
target_environment: &str,
) -> Result<ApprovalState, PlatformError>;
async fn approve_plan_stage(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
) -> Result<(), PlatformError>;
async fn reject_plan_stage(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
reason: Option<&str>,
) -> Result<(), PlatformError>;
async fn get_plan_output(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
) -> Result<PlanOutput, PlatformError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanOutput {
pub plan_output: String,
pub status: String,
pub outputs: Vec<PlanDestinationOutput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanDestinationOutput {
pub destination_id: String,
pub destination_name: String,
pub plan_output: String,
pub status: String,
}
#[cfg(test)]

View File

@@ -654,13 +654,28 @@ pub struct GetPlanOutputRequest {
#[prost(string, tag="2")]
pub stage_id: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetPlanOutputResponse {
/// deprecated: use outputs
#[prost(string, tag="1")]
pub plan_output: ::prost::alloc::string::String,
/// RUNNING, AWAITING_APPROVAL, APPROVED, REJECTED
#[prost(string, tag="2")]
pub status: ::prost::alloc::string::String,
#[prost(message, repeated, tag="3")]
pub outputs: ::prost::alloc::vec::Vec<PlanDestinationOutput>,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct PlanDestinationOutput {
#[prost(string, tag="1")]
pub destination_id: ::prost::alloc::string::String,
#[prost(string, tag="2")]
pub destination_name: ::prost::alloc::string::String,
#[prost(string, tag="3")]
pub plan_output: ::prost::alloc::string::String,
/// SUCCEEDED, FAILED, RUNNING, etc.
#[prost(string, tag="4")]
pub status: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct Source {
@@ -2813,7 +2828,7 @@ pub struct PolicyEvaluation {
#[prost(string, tag="4")]
pub reason: ::prost::alloc::string::String,
#[prost(message, optional, tag="10")]
pub approval_state: ::core::option::Option<ExternalApprovalState>,
pub external_approval_state: ::core::option::Option<ExternalApprovalState>,
}
// ── CRUD messages ───────────────────────────────────────────────────
@@ -3404,6 +3419,9 @@ pub struct WorkAssignment {
/// Full destination configuration including metadata.
#[prost(message, optional, tag="6")]
pub destination: ::core::option::Option<DestinationInfo>,
/// Execution mode. Defaults to DEPLOY if unset.
#[prost(enumeration="ReleaseMode", tag="7")]
pub mode: i32,
}
/// Destination configuration sent with the work assignment.
#[derive(Clone, PartialEq, ::prost::Message)]
@@ -3526,10 +3544,47 @@ pub struct CompleteReleaseRequest {
/// Error description when outcome is FAILURE.
#[prost(string, tag="3")]
pub error_message: ::prost::alloc::string::String,
/// Plan output text when mode was "plan" and outcome is SUCCESS.
/// Stored in release_states.plan_output for UI review.
#[prost(string, optional, tag="4")]
pub plan_output: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct CompleteReleaseResponse {
}
/// Execution mode for a work assignment.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum ReleaseMode {
Unspecified = 0,
/// Normal deployment execution.
Deploy = 1,
/// Dry-run / plan only (e.g. terraform plan). Runner should capture
/// plan output and include it in CompleteRelease.plan_output.
Plan = 2,
}
impl ReleaseMode {
/// String value of the enum field names used in the ProtoBuf definition.
///
/// The values are not transformed in any way and thus are considered stable
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
pub fn as_str_name(&self) -> &'static str {
match self {
Self::Unspecified => "RELEASE_MODE_UNSPECIFIED",
Self::Deploy => "RELEASE_MODE_DEPLOY",
Self::Plan => "RELEASE_MODE_PLAN",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
match value {
"RELEASE_MODE_UNSPECIFIED" => Some(Self::Unspecified),
"RELEASE_MODE_DEPLOY" => Some(Self::Deploy),
"RELEASE_MODE_PLAN" => Some(Self::Plan),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum ReleaseOutcome {

View File

@@ -6,7 +6,7 @@ use forage_core::platform::{
ApprovalDecisionEntry, ApprovalState, Artifact, ArtifactContext, ArtifactDestination,
ArtifactRef, ArtifactSource, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput,
Destination, DestinationType, Environment, ForestPlatform, NotificationPreference, Organisation,
OrgMember, PipelineStage, PipelineStageConfig, PlatformError, Policy, PolicyConfig,
OrgMember, PipelineStage, PipelineStageConfig, PlanOutput, PlatformError, Policy, PolicyConfig,
PolicyEvaluation, ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput,
UpdateTriggerInput,
};
@@ -583,8 +583,8 @@ fn convert_pipeline_stage(s: forage_grpc::PipelineStage) -> PipelineStage {
Some(forage_grpc::pipeline_stage::Config::Wait(w)) => {
PipelineStageConfig::Wait { duration_seconds: w.duration_seconds }
}
Some(forage_grpc::pipeline_stage::Config::Plan(_)) => {
PipelineStageConfig::Deploy { environment: String::new() }
Some(forage_grpc::pipeline_stage::Config::Plan(p)) => {
PipelineStageConfig::Plan { environment: p.environment, auto_approve: p.auto_approve }
}
None => PipelineStageConfig::Deploy { environment: String::new() },
};
@@ -603,6 +603,7 @@ fn convert_pipeline_stage_state(
let stage_type = match forage_grpc::PipelineRunStageType::try_from(s.stage_type) {
Ok(forage_grpc::PipelineRunStageType::Deploy) => "deploy",
Ok(forage_grpc::PipelineRunStageType::Wait) => "wait",
Ok(forage_grpc::PipelineRunStageType::Plan) => "plan",
_ => "unknown",
};
let status = match forage_grpc::PipelineRunStageStatus::try_from(s.status) {
@@ -611,6 +612,7 @@ fn convert_pipeline_stage_state(
Ok(forage_grpc::PipelineRunStageStatus::Succeeded) => "SUCCEEDED",
Ok(forage_grpc::PipelineRunStageStatus::Failed) => "FAILED",
Ok(forage_grpc::PipelineRunStageStatus::Cancelled) => "CANCELLED",
Ok(forage_grpc::PipelineRunStageStatus::AwaitingApproval) => "AWAITING_APPROVAL",
_ => "PENDING",
};
forage_core::platform::PipelineRunStageState {
@@ -626,6 +628,8 @@ fn convert_pipeline_stage_state(
error_message: s.error_message,
wait_until: s.wait_until,
release_ids: s.release_ids,
approval_status: s.approval_status,
auto_approve: s.auto_approve,
}
}
@@ -663,6 +667,12 @@ fn convert_stages_to_grpc(stages: &[PipelineStage]) -> Vec<forage_grpc::Pipeline
duration_seconds: *duration_seconds,
})
}
PipelineStageConfig::Plan { environment, auto_approve } => {
forage_grpc::pipeline_stage::Config::Plan(forage_grpc::PlanStageConfig {
environment: environment.clone(),
auto_approve: *auto_approve,
})
}
}),
})
.collect()
@@ -1872,6 +1882,81 @@ impl ForestPlatform for GrpcForestClient {
.map_err(map_platform_status)?;
Ok(convert_approval_state(resp.into_inner().state))
}
async fn approve_plan_stage(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
) -> Result<(), PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::ApprovePlanStageRequest {
release_intent_id: release_intent_id.into(),
stage_id: stage_id.into(),
},
)?;
self.release_client()
.approve_plan_stage(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn reject_plan_stage(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
reason: Option<&str>,
) -> Result<(), PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::RejectPlanStageRequest {
release_intent_id: release_intent_id.into(),
stage_id: stage_id.into(),
reason: reason.map(|s| s.into()),
},
)?;
self.release_client()
.reject_plan_stage(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn get_plan_output(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
) -> Result<PlanOutput, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::GetPlanOutputRequest {
release_intent_id: release_intent_id.into(),
stage_id: stage_id.into(),
},
)?;
let resp = self
.release_client()
.get_plan_output(req)
.await
.map_err(map_platform_status)?;
let inner = resp.into_inner();
Ok(PlanOutput {
plan_output: inner.plan_output,
status: inner.status,
outputs: inner.outputs.into_iter().map(|o| {
forage_core::platform::PlanDestinationOutput {
destination_id: o.destination_id,
destination_name: o.destination_name,
plan_output: o.plan_output,
status: o.status,
}
}).collect(),
})
}
}
fn convert_policy_evaluation(e: forage_grpc::PolicyEvaluation) -> PolicyEvaluation {
@@ -1881,7 +1966,7 @@ fn convert_policy_evaluation(e: forage_grpc::PolicyEvaluation) -> PolicyEvaluati
3 => "approval",
_ => "unknown",
};
let approval_state = e.approval_state.map(|s| convert_approval_state(Some(s)));
let approval_state = e.external_approval_state.map(|s| convert_approval_state(Some(s)));
PolicyEvaluation {
policy_name: e.policy_name,
policy_type: policy_type.into(),

View File

@@ -11,6 +11,7 @@ mod templates;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use forage_core::session::{FileSessionStore, SessionStore};
use forage_db::PgSessionStore;
@@ -54,8 +55,8 @@ fn init_telemetry() {
)
.build();
let otel_layer = tracing_opentelemetry::layer()
.with_tracer(tracer_provider.tracer("forage-server"));
let otel_layer =
tracing_opentelemetry::layer().with_tracer(tracer_provider.tracer("forage-server"));
tracing_subscriber::registry()
.with(env_filter)
@@ -119,7 +120,10 @@ async fn main() -> anyhow::Result<()> {
let mut mad = notmad::Mad::builder();
// Session store + integration store: PostgreSQL if DATABASE_URL is set
let (sessions, integration_store): (Arc<dyn SessionStore>, Option<Arc<dyn forage_core::integrations::IntegrationStore>>);
let (sessions, integration_store): (
Arc<dyn SessionStore>,
Option<Arc<dyn forage_core::integrations::IntegrationStore>>,
);
if let Ok(database_url) = std::env::var("DATABASE_URL") {
tracing::info!("using PostgreSQL session store");
@@ -129,12 +133,16 @@ async fn main() -> anyhow::Result<()> {
let pg_store = Arc::new(PgSessionStore::new(pool.clone()));
// Integration store (uses same pool)
let encryption_key = std::env::var("INTEGRATION_ENCRYPTION_KEY")
.unwrap_or_else(|_| {
tracing::warn!("INTEGRATION_ENCRYPTION_KEY not set — using default key (not safe for production)");
"forage-dev-key-not-for-production!!".to_string()
});
let pg_integrations = Arc::new(forage_db::PgIntegrationStore::new(pool, encryption_key.into_bytes()));
let encryption_key = std::env::var("INTEGRATION_ENCRYPTION_KEY").unwrap_or_else(|_| {
tracing::warn!(
"INTEGRATION_ENCRYPTION_KEY not set — using default key (not safe for production)"
);
"forage-dev-key-not-for-production!!".to_string()
});
let pg_integrations = Arc::new(forage_db::PgIntegrationStore::new(
pool,
encryption_key.into_bytes(),
));
// Session reaper component
mad.add(session_reaper::PgSessionReaper {
@@ -143,11 +151,15 @@ async fn main() -> anyhow::Result<()> {
});
sessions = pg_store;
integration_store = Some(pg_integrations as Arc<dyn forage_core::integrations::IntegrationStore>);
integration_store =
Some(pg_integrations as Arc<dyn forage_core::integrations::IntegrationStore>);
} else {
let session_dir = std::env::var("SESSION_DIR").unwrap_or_else(|_| "target/sessions".into());
tracing::info!("using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)");
let file_store = Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir"));
tracing::info!(
"using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)"
);
let file_store =
Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir"));
// File session reaper component
mad.add(session_reaper::FileSessionReaper {
@@ -159,8 +171,13 @@ async fn main() -> anyhow::Result<()> {
};
let forest_client = Arc::new(forest_client);
let mut state = AppState::new(template_engine, forest_client.clone(), forest_client.clone(), sessions)
.with_grpc_client(forest_client.clone());
let mut state = AppState::new(
template_engine,
forest_client.clone(),
forest_client.clone(),
sessions,
)
.with_grpc_client(forest_client.clone());
// Slack OAuth config (optional, enables "Add to Slack" button)
if let (Ok(client_id), Ok(client_secret)) = (
@@ -220,7 +237,9 @@ async fn main() -> anyhow::Result<()> {
});
} else {
// Fallback: direct dispatch (no durability)
tracing::warn!("NATS_URL not set — using direct notification dispatch (no durability)");
tracing::warn!(
"NATS_URL not set — using direct notification dispatch (no durability)"
);
mad.add(notification_worker::NotificationListener {
grpc: forest_client,
store: store.clone(),
@@ -234,12 +253,11 @@ async fn main() -> anyhow::Result<()> {
}
// HTTP server component
mad.add(serve_http::ServeHttp {
addr,
state,
});
mad.add(serve_http::ServeHttp { addr, state });
mad.run().await?;
mad.cancellation(Some(Duration::from_secs(10)))
.run()
.await?;
Ok(())
}

View File

@@ -125,6 +125,18 @@ pub fn router() -> Router<AppState> {
post(delete_pipeline),
)
.route("/users/{username}", get(user_profile))
.route(
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/approve",
post(approve_plan_stage_submit),
)
.route(
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/reject",
post(reject_plan_stage_submit),
)
.route(
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/output",
get(get_plan_output_api),
)
.route(
"/api/orgs/{org}/projects/{project}/timeline",
get(timeline_api),
@@ -402,7 +414,12 @@ async fn fetch_notifications(
if let Some(run_stages) = intent_stages_by_artifact.get(aid) {
let sorted = topo_sort_run_stages(run_stages);
for rs in sorted {
let display_status = deploy_stage_display_status(rs, &matching_states);
let base_status = deploy_stage_display_status(rs, &matching_states);
let display_status = if rs.stage_type == "plan" && rs.approval_status.as_deref() == Some("AWAITING_APPROVAL") {
"AWAITING_APPROVAL"
} else {
base_status
};
pipeline_stages.push(context! {
id => rs.stage_id,
stage_type => rs.stage_type,
@@ -413,6 +430,7 @@ async fn fetch_notifications(
completed_at => rs.completed_at,
error_message => rs.error_message,
wait_until => rs.wait_until,
approval_status => rs.approval_status,
});
}
}
@@ -895,7 +913,7 @@ async fn artifact_detail(
));
}
let (artifact_result, projects, dest_states, release_intents, pipelines) = tokio::join!(
let (artifact_result, projects, dest_states, release_intents, pipelines, environments) = tokio::join!(
state
.platform_client
.get_artifact_by_slug(&session.access_token, &slug),
@@ -911,6 +929,9 @@ async fn artifact_detail(
state
.platform_client
.list_release_pipelines(&session.access_token, &org, &project),
state
.platform_client
.list_environments(&session.access_token, &org),
);
// Fetch artifact spec after we have the artifact_id (needs artifact_result first).
@@ -954,44 +975,62 @@ async fn artifact_detail(
.any(|ri| ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty())
});
// Build pipeline stages from intent data.
// Build pipeline stages from the most recent release intent for this artifact.
let mut pipeline_stages: Vec<minijinja::Value> = Vec::new();
for ri in &release_intents {
if ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty() {
let sorted = topo_sort_run_stages(&ri.stages);
for rs in sorted {
let display_status = deploy_stage_display_status(rs, &matching_states);
pipeline_stages.push(context! {
id => rs.stage_id,
stage_type => rs.stage_type,
environment => rs.environment,
duration_seconds => rs.duration_seconds,
status => display_status,
started_at => rs.started_at,
completed_at => rs.completed_at,
error_message => rs.error_message,
wait_until => rs.wait_until,
});
}
let latest_intent = release_intents
.iter()
.filter(|ri| ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty())
.max_by_key(|ri| &ri.created_at);
if let Some(ri) = latest_intent {
let sorted = topo_sort_run_stages(&ri.stages);
for rs in sorted {
let base_status = deploy_stage_display_status(rs, &matching_states);
let display_status = if rs.stage_type == "plan" && rs.approval_status.as_deref() == Some("AWAITING_APPROVAL") {
"AWAITING_APPROVAL"
} else {
base_status
};
pipeline_stages.push(context! {
id => rs.stage_id,
stage_type => rs.stage_type,
environment => rs.environment,
duration_seconds => rs.duration_seconds,
status => display_status,
started_at => rs.started_at,
completed_at => rs.completed_at,
error_message => rs.error_message,
wait_until => rs.wait_until,
approval_status => rs.approval_status,
});
}
}
let has_pipeline = !pipeline_stages.is_empty() || project_has_pipeline;
// Fetch policy evaluations for active release intents.
let mut policy_evaluations: Vec<minijinja::Value> = Vec::new();
let mut release_intent_id_str = String::new();
struct PolicyEvalEntry {
policy_name: String,
policy_type: String,
passed: bool,
reason: String,
target_environment: String,
approval_state: Option<forage_core::platform::ApprovalState>,
}
let mut raw_evals: Vec<PolicyEvalEntry> = Vec::new();
let release_intent_id_str = latest_intent
.map(|ri| ri.release_intent_id.clone())
.unwrap_or_default();
let is_release_author = false;
for ri in &release_intents {
if ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty() {
release_intent_id_str = ri.release_intent_id.clone();
// Collect unique environments from the pipeline stages.
if let Some(ri) = latest_intent {
{
let mut seen = std::collections::BTreeSet::new();
let environments: Vec<String> = ri
.stages
.iter()
.filter_map(|s| s.environment.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.filter(|e| seen.insert(e.clone()))
.collect();
for env in &environments {
@@ -1007,40 +1046,55 @@ async fn artifact_detail(
.await
{
for eval in evals {
let approval_state_ctx = eval.approval_state.as_ref().map(|s| {
let decisions: Vec<minijinja::Value> = s
.decisions
.iter()
.map(|d| {
context! {
username => d.username,
decision => d.decision,
comment => d.comment,
decided_at => d.decided_at,
}
})
.collect();
context! {
required_approvals => s.required_approvals,
current_approvals => s.current_approvals,
decisions => decisions,
}
});
policy_evaluations.push(context! {
policy_name => eval.policy_name,
policy_type => eval.policy_type,
passed => eval.passed,
reason => eval.reason,
target_environment => env,
approval_state => approval_state_ctx,
raw_evals.push(PolicyEvalEntry {
policy_name: eval.policy_name,
policy_type: eval.policy_type,
passed: eval.passed,
reason: eval.reason,
target_environment: env.clone(),
approval_state: eval.approval_state,
});
}
}
}
break; // Only one active intent per artifact.
}
}
raw_evals.sort_by(|a, b| a.policy_type.cmp(&b.policy_type).then(a.policy_name.cmp(&b.policy_name)));
let policy_evaluations: Vec<minijinja::Value> = raw_evals
.iter()
.map(|eval| {
let approval_state_ctx = eval.approval_state.as_ref().map(|s| {
let decisions: Vec<minijinja::Value> = s
.decisions
.iter()
.map(|d| {
context! {
username => d.username,
decision => d.decision,
comment => d.comment,
decided_at => d.decided_at,
}
})
.collect();
context! {
required_approvals => s.required_approvals,
current_approvals => s.current_approvals,
decisions => decisions,
}
});
context! {
policy_name => eval.policy_name,
policy_type => eval.policy_type,
passed => eval.passed,
reason => eval.reason,
target_environment => eval.target_environment,
approval_state => approval_state_ctx,
}
})
.collect();
let current_org_entry = orgs.iter().find(|o| o.name == org);
let is_admin = current_org_entry
.map(|o| o.role == "owner" || o.role == "admin")
@@ -1066,6 +1120,8 @@ async fn artifact_detail(
})
.collect();
let artifact_id_val = artifact.artifact_id.clone();
let html = state
.templates
.render(
@@ -1127,6 +1183,12 @@ async fn artifact_detail(
release_intent_id => &release_intent_id_str,
is_release_author => is_release_author,
is_admin => is_admin,
artifact_id => &artifact_id_val,
has_active_pipeline => has_pipeline,
environments => warn_default("list_environments", environments)
.iter()
.map(|e| context! { name => e.name })
.collect::<Vec<_>>(),
},
)
.map_err(|e| {
@@ -2102,6 +2164,10 @@ pub struct ApiPipelineStage {
pub wait_until: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<bool>,
}
#[derive(Debug, Serialize)]
@@ -2287,6 +2353,14 @@ fn build_timeline_json(
} else {
None
};
// For plan stages, use AWAITING_APPROVAL as display status when appropriate
let display_status = if rs.stage_type == "plan"
&& rs.approval_status.as_deref() == Some("AWAITING_APPROVAL")
{
"AWAITING_APPROVAL".to_string()
} else {
display_status
};
stages.push(ApiPipelineStage {
id: rs.stage_id.clone(),
stage_type: rs.stage_type.clone(),
@@ -2298,6 +2372,8 @@ fn build_timeline_json(
error_message: rs.error_message.clone(),
wait_until: rs.wait_until.clone(),
blocked_by,
approval_status: rs.approval_status.clone(),
auto_approve: rs.auto_approve,
});
}
}
@@ -2411,7 +2487,10 @@ fn build_timeline_json(
let mut seen_deployed = false;
for raw in raw_releases {
let needs_action = raw.release.pipeline_stages.iter().any(|s| s.blocked_by.is_some());
let needs_action = raw.release.pipeline_stages.iter().any(|s| {
s.blocked_by.is_some()
|| (s.stage_type == "plan" && s.status == "AWAITING_APPROVAL")
});
if raw.has_dests || needs_action {
if !hidden_buf.is_empty() {
let count = hidden_buf.len();
@@ -4758,3 +4837,155 @@ async fn reject_release_submit(
.into_response())
}
// ── Plan stage approve / reject / output ─────────────────────────────
#[derive(Deserialize)]
struct PlanStageForm {
csrf_token: String,
release_intent_id: String,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
redirect_to: Option<String>,
}
async fn approve_plan_stage_submit(
State(state): State<AppState>,
session: Session,
headers: axum::http::HeaderMap,
Path((org, _project, stage_id)): Path<(String, String, String)>,
Form(form): Form<PlanStageForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
if form.csrf_token != session.csrf_token {
return Err(approval_error(
&state,
&headers,
StatusCode::FORBIDDEN,
"CSRF validation failed. Please try again.",
));
}
state
.platform_client
.approve_plan_stage(
&session.access_token,
&form.release_intent_id,
&stage_id,
)
.await
.map_err(|e| match e {
forage_core::platform::PlatformError::NotAuthenticated => {
axum::response::Redirect::to("/login").into_response()
}
other => approval_error(
&state,
&headers,
StatusCode::INTERNAL_SERVER_ERROR,
&format!("{other}"),
),
})?;
if let Some(redirect) = &form.redirect_to {
Ok(Redirect::to(redirect).into_response())
} else {
Ok(Json(serde_json::json!({ "ok": true })).into_response())
}
}
async fn reject_plan_stage_submit(
State(state): State<AppState>,
session: Session,
headers: axum::http::HeaderMap,
Path((org, _project, stage_id)): Path<(String, String, String)>,
Form(form): Form<PlanStageForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
if form.csrf_token != session.csrf_token {
return Err(approval_error(
&state,
&headers,
StatusCode::FORBIDDEN,
"CSRF validation failed. Please try again.",
));
}
let reason = form.reason.as_deref().and_then(|s| {
let t = s.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
});
state
.platform_client
.reject_plan_stage(
&session.access_token,
&form.release_intent_id,
&stage_id,
reason.as_deref(),
)
.await
.map_err(|e| match e {
forage_core::platform::PlatformError::NotAuthenticated => {
axum::response::Redirect::to("/login").into_response()
}
other => approval_error(
&state,
&headers,
StatusCode::INTERNAL_SERVER_ERROR,
&format!("{other}"),
),
})?;
if let Some(redirect) = &form.redirect_to {
Ok(Redirect::to(redirect).into_response())
} else {
Ok(Json(serde_json::json!({ "ok": true })).into_response())
}
}
#[derive(Deserialize)]
struct PlanOutputQuery {
release_intent_id: String,
}
async fn get_plan_output_api(
State(state): State<AppState>,
session: Session,
Path((org, _project, stage_id)): Path<(String, String, String)>,
Query(query): Query<PlanOutputQuery>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
let output = state
.platform_client
.get_plan_output(
&session.access_token,
&query.release_intent_id,
&stage_id,
)
.await
.map_err(|e| {
internal_error(&state, "get plan output", &e)
})?;
let outputs: Vec<serde_json::Value> = output.outputs.iter().map(|o| {
serde_json::json!({
"destination_id": o.destination_id,
"destination_name": o.destination_name,
"plan_output": o.plan_output,
"status": o.status,
})
}).collect();
Ok(Json(serde_json::json!({
"plan_output": output.plan_output,
"status": output.status,
"outputs": outputs,
}))
.into_response())
}

View File

@@ -1,5 +1,6 @@
use std::net::SocketAddr;
use anyhow::Context;
use notmad::{Component, ComponentInfo, MadError};
use tokio_util::sync::CancellationToken;
@@ -20,7 +21,7 @@ impl Component for ServeHttp {
let listener = tokio::net::TcpListener::bind(self.addr)
.await
.map_err(|e| MadError::Inner(e.into()))?;
.context("failed to listen on port")?;
tracing::info!("listening on {}", self.addr);
@@ -29,7 +30,7 @@ impl Component for ServeHttp {
cancellation_token.cancelled().await;
})
.await
.map_err(|e| MadError::Inner(e.into()))?;
.context("failed to run axum server")?;
Ok(())
}

View File

@@ -773,6 +773,38 @@ impl ForestPlatform for MockPlatformClient {
decisions: vec![],
})
}
async fn approve_plan_stage(
&self,
_access_token: &str,
_release_intent_id: &str,
_stage_id: &str,
) -> Result<(), PlatformError> {
Ok(())
}
async fn reject_plan_stage(
&self,
_access_token: &str,
_release_intent_id: &str,
_stage_id: &str,
_reason: Option<&str>,
) -> Result<(), PlatformError> {
Ok(())
}
async fn get_plan_output(
&self,
_access_token: &str,
_release_intent_id: &str,
_stage_id: &str,
) -> Result<forage_core::platform::PlanOutput, PlatformError> {
Ok(forage_core::platform::PlanOutput {
plan_output: String::new(),
status: "RUNNING".into(),
outputs: vec![],
})
}
}
pub(crate) fn make_templates() -> TemplateEngine {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -4,7 +4,7 @@
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";
import { pipelineSummary, deployStageLabel, waitStageLabel, planStageLabel, STATUS_CONFIG } from "./lib/status.js";
// Props from attributes
export let org = "";
@@ -96,6 +96,88 @@
}
}
// ── Plan stage actions ──────────────────────────────────────────
let planOutputs = {}; // keyed by "intentId:stageId"
let planOutputLoading = new Set();
async function approvePlanStage(release, stage, reject = false) {
const key = `plan:${release.release_intent_id}:${stage.id}`;
if (approving.has(key)) return;
approving.add(key);
approving = approving;
approvalError = null;
try {
const action = reject ? "reject" : "approve";
const formData = new URLSearchParams();
formData.set("csrf_token", csrf);
formData.set("release_intent_id", release.release_intent_id);
const res = await fetch(
`/api/orgs/${org}/projects/${release.project_name || project}/plan-stages/${stage.id}/${action}`,
{
method: "POST",
body: formData,
credentials: "same-origin",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
}
);
if (res.ok) {
await refreshData();
} else {
const text = await res.text().catch(() => "");
let msg;
try { msg = JSON.parse(text).error; } catch {}
approvalError = msg || `Plan ${action} failed (${res.status})`;
setTimeout(() => { approvalError = null; }, 8000);
}
} catch (err) {
approvalError = err.message || "Plan action failed";
setTimeout(() => { approvalError = null; }, 8000);
} finally {
approving.delete(key);
approving = approving;
}
}
async function viewPlanOutput(release, stage) {
const key = `${release.release_intent_id}:${stage.id}`;
if (planOutputLoading.has(key)) return;
if (planOutputs[key]) {
// Toggle off
delete planOutputs[key];
planOutputs = planOutputs;
return;
}
planOutputLoading.add(key);
planOutputLoading = planOutputLoading;
try {
const res = await fetch(
`/api/orgs/${org}/projects/${release.project_name || project}/plan-stages/${stage.id}/output?release_intent_id=${encodeURIComponent(release.release_intent_id)}`,
{ credentials: "same-origin", headers: { "Accept": "application/json" } }
);
if (res.ok) {
const data = await res.json();
planOutputs[key] = data;
planOutputs = planOutputs;
} else {
approvalError = `Failed to load plan output (${res.status})`;
setTimeout(() => { approvalError = null; }, 8000);
}
} catch (err) {
approvalError = err.message || "Failed to load plan output";
setTimeout(() => { approvalError = null; }, 8000);
} finally {
planOutputLoading.delete(key);
planOutputLoading = planOutputLoading;
}
}
// ── Data fetching ────────────────────────────────────────────────
// Debounce re-fetches: multiple SSE events within 300ms only trigger one fetch
@@ -428,6 +510,21 @@
}
}
// Normalize plan stage status: the API returns status="RUNNING" with
// approval_status="AWAITINGAPPROVAL" (no underscore, Debug format from Rust).
// Map this to a single effective status for template rendering.
function effectiveStatus(stage) {
if (stage.stage_type === "plan" && stage.approval_status &&
(stage.approval_status === "AWAITINGAPPROVAL" || stage.approval_status === "AWAITING_APPROVAL")) {
return "AWAITING_APPROVAL";
}
return stage.status;
}
function isPlanAwaiting(stage) {
return stage.stage_type === "plan" && effectiveStatus(stage) === "AWAITING_APPROVAL";
}
$: laneCount = lanes.length;
$: gutterWidth = laneCount * (BAR_WIDTH + BAR_GAP) + 8;
</script>
@@ -559,6 +656,18 @@
<span class="w-1.5 h-1.5 rounded-full {dot}"></span>
</span>
{/if}
{#if stage.stage_type === "plan" && isPlanAwaiting(stage) && release.release_intent_id && csrf}
{@const planBadge = envBadgeClasses(stage.environment || "")}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-purple-100">
{stage.environment} plan
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
</span>
<button
class="text-xs px-2 py-0.5 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors disabled:opacity-50"
disabled={approving.has(`plan:${release.release_intent_id}:${stage.id}`)}
on:click|stopPropagation={() => approvePlanStage(release, stage)}
>Approve plan</button>
{/if}
{#if stage.blocked_by && release.release_intent_id && csrf}
{#if isAuthor(release) && isAdmin()}
<button
@@ -630,15 +739,18 @@
{#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"}
{@const stageStatus = effectiveStatus(stage)}
<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' : ''} {stageStatus === 'PENDING' ? 'opacity-50' : ''}">
{#if stageStatus === "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"}
{:else if stageStatus === "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"}
{:else if stageStatus === "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"}
{:else if stageStatus === "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 if stageStatus === "AWAITING_APPROVAL"}
<svg class="w-4 h-4 text-purple-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-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>
{: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}
@@ -656,9 +768,37 @@
<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>
{:else if stage.stage_type === "plan"}
<span class="text-sm {stageStatus === 'AWAITING_APPROVAL' ? 'text-purple-700' : stageStatus === 'SUCCEEDED' ? 'text-gray-700' : stageStatus === 'RUNNING' ? 'text-yellow-700' : stageStatus === 'FAILED' ? 'text-red-700' : 'text-gray-400'}">
{planStageLabel(stageStatus)}
</span>
{@const planBadge = envBadgeClasses(stage.environment || "")}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {planBadge.bg}">
{stage.environment}
<span class="w-1.5 h-1.5 rounded-full {planBadge.dot}"></span>
</span>
{#if stageStatus === "AWAITING_APPROVAL" && release.release_intent_id && csrf}
<button
class="text-xs px-2 py-0.5 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors disabled:opacity-50"
disabled={approving.has(`plan:${release.release_intent_id}:${stage.id}`)}
on:click|stopPropagation={() => approvePlanStage(release, stage)}
>Approve plan</button>
<button
class="text-xs px-2 py-0.5 rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-50"
disabled={approving.has(`plan:${release.release_intent_id}:${stage.id}`)}
on:click|stopPropagation={() => { if (confirm('Reject this plan?')) approvePlanStage(release, stage, true); }}
>Reject</button>
{/if}
{#if (stageStatus === "AWAITING_APPROVAL" || stageStatus === "SUCCEEDED" || stageStatus === "FAILED") && release.release_intent_id}
<button
class="text-xs px-2 py-0.5 rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-50"
disabled={planOutputLoading.has(`${release.release_intent_id}:${stage.id}`)}
on:click|stopPropagation={() => viewPlanOutput(release, stage)}
>{planOutputs[`${release.release_intent_id}:${stage.id}`] ? "Hide plan" : "View plan"}</button>
{/if}
{/if}
{#if stage.started_at && (stage.status === "RUNNING" || stage.status === "QUEUED" || stage.completed_at)}
{#if stage.started_at && (stageStatus === "RUNNING" || stageStatus === "QUEUED" || stageStatus === "AWAITING_APPROVAL" || stage.completed_at)}
<span class="text-xs text-gray-400 tabular-nums">{elapsedStr(stage.started_at, stage.completed_at, stage.status)}</span>
{/if}
@@ -667,6 +807,28 @@
pipeline
</span>
</div>
{#if stage.stage_type === "plan" && planOutputs[`${release.release_intent_id}:${stage.id}`]}
{@const planData = planOutputs[`${release.release_intent_id}:${stage.id}`]}
<div class="px-4 py-3 bg-gray-50 border-t border-gray-100 space-y-3">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500">Plan output</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700">{planData.status}</span>
</div>
{#if planData.outputs && planData.outputs.length > 0}
{#each planData.outputs as destOutput (destOutput.destination_id)}
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-gray-600">{destOutput.destination_name}</span>
<span class="text-xs text-gray-400">{destOutput.status}</span>
</div>
<pre class="text-xs font-mono text-gray-700 whitespace-pre-wrap bg-white border border-gray-200 rounded p-3 max-h-48 overflow-auto">{destOutput.plan_output || "(no output)"}</pre>
</div>
{/each}
{:else}
<pre class="text-xs font-mono text-gray-700 whitespace-pre-wrap bg-white border border-gray-200 rounded p-3 max-h-64 overflow-auto">{planData.plan_output || "(no output)"}</pre>
{/if}
</div>
{/if}
{/each}
</div>
{/if}

View File

@@ -25,9 +25,11 @@ export function pipelineSummary(stages) {
}
let anyApprovalBlocked = stages.some(s => s.blocked_by);
let anyPlanAwaiting = stages.some(s => s.stage_type === "plan" && (s.status === "AWAITING_APPROVAL" || s.approval_status === "AWAITINGAPPROVAL" || s.approval_status === "AWAITING_APPROVAL"));
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 (anyPlanAwaiting) return { label: "Awaiting plan approval", color: "text-purple-700", icon: "shield", iconColor: "text-purple-500", done, total };
if (anyApprovalBlocked) return { label: "Awaiting approval", color: "text-emerald-700", icon: "shield", iconColor: "text-emerald-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 };
@@ -64,3 +66,14 @@ export function deployStageLabel(status) {
default: return "Deploy to";
}
}
export function planStageLabel(status) {
switch (status) {
case "SUCCEEDED": return "Plan approved";
case "RUNNING": return "Planning";
case "AWAITING_APPROVAL": return "Awaiting plan approval";
case "FAILED": return "Plan failed";
case "CANCELLED": return "Plan cancelled";
default: return "Plan";
}
}

View File

@@ -76,7 +76,7 @@ message PolicyEvaluation {
bool passed = 3;
// Human-readable explanation when blocked
string reason = 4;
optional ExternalApprovalState approval_state = 10;
optional ExternalApprovalState external_approval_state = 10;
}
// ── CRUD messages ───────────────────────────────────────────────────

View File

@@ -297,8 +297,16 @@ message GetPlanOutputRequest {
string stage_id = 2;
}
message GetPlanOutputResponse {
string plan_output = 1;
string status = 2; // RUNNING, AWAITING_APPROVAL, APPROVED, REJECTED
string plan_output = 1; // deprecated: use outputs
string status = 2; // RUNNING, AWAITING_APPROVAL, APPROVED, REJECTED
repeated PlanDestinationOutput outputs = 3;
}
message PlanDestinationOutput {
string destination_id = 1;
string destination_name = 2;
string plan_output = 3;
string status = 4; // SUCCEEDED, FAILED, RUNNING, etc.
}
service ReleaseService {

View File

@@ -99,6 +99,16 @@ message RegisterAck {
string reason = 3;
}
// Execution mode for a work assignment.
enum ReleaseMode {
RELEASE_MODE_UNSPECIFIED = 0;
// Normal deployment execution.
RELEASE_MODE_DEPLOY = 1;
// Dry-run / plan only (e.g. terraform plan). Runner should capture
// plan output and include it in CompleteRelease.plan_output.
RELEASE_MODE_PLAN = 2;
}
// Work assignment pushed to a runner when a matching release is available.
message WorkAssignment {
// Scoped opaque auth token. Use this for GetReleaseFiles, PushLogs,
@@ -111,6 +121,8 @@ message WorkAssignment {
string destination_id = 5;
// Full destination configuration including metadata.
DestinationInfo destination = 6;
// Execution mode. Defaults to DEPLOY if unset.
ReleaseMode mode = 7;
}
// Destination configuration sent with the work assignment.
@@ -201,6 +213,9 @@ message CompleteReleaseRequest {
ReleaseOutcome outcome = 2;
// Error description when outcome is FAILURE.
string error_message = 3;
// Plan output text when mode was "plan" and outcome is SUCCESS.
// Stored in release_states.plan_output for UI review.
optional string plan_output = 4;
}
enum ReleaseOutcome {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -42,6 +42,7 @@ class PipelineBuilder extends HTMLElement {
if (!config) return "deploy";
if (config.Deploy !== undefined) return "deploy";
if (config.Wait !== undefined) return "wait";
if (config.Plan !== undefined) return "plan";
return "deploy";
}
@@ -50,6 +51,7 @@ class PipelineBuilder extends HTMLElement {
if (!config) return "";
if (config.Deploy) return config.Deploy.environment || "";
if (config.Wait) return config.Wait.duration_seconds ? `${config.Wait.duration_seconds}s` : "";
if (config.Plan) return config.Plan.environment || "";
return "";
}
@@ -367,7 +369,7 @@ class PipelineBuilder extends HTMLElement {
// 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"]) {
for (const t of ["deploy", "wait", "plan"]) {
const opt = document.createElement("option");
opt.value = t;
opt.textContent = t;
@@ -379,6 +381,8 @@ class PipelineBuilder extends HTMLElement {
clearTimeout(this._blurTimer);
if (typeSelect.value === "wait") {
this.stages[index].config = { Wait: { duration_seconds: 0 } };
} else if (typeSelect.value === "plan") {
this.stages[index].config = { Plan: { environment: "", auto_approve: false } };
} else {
this.stages[index].config = { Deploy: { environment: "" } };
}
@@ -440,6 +444,33 @@ class PipelineBuilder extends HTMLElement {
};
const secLabel = el("span", "text-xs text-gray-400", "seconds");
configRow.append(durLabel, durInput, secLabel);
} else if (type === "plan") {
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.Plan && stage.config.Plan.environment) || "";
envInput.placeholder = "environment";
envInput.onmousedown = (e) => e.stopPropagation();
envInput.oninput = () => {
if (!this.stages[index].config.Plan) this.stages[index].config = { Plan: { environment: "", auto_approve: false } };
this.stages[index].config.Plan.environment = envInput.value.trim();
this._sync();
};
envInput.onblur = () => {
this._blurTimer = setTimeout(() => this._render(), 150);
};
const autoLabel = el("label", "text-xs text-gray-500 flex items-center gap-1 ml-2 shrink-0");
const autoCheck = el("input", "");
autoCheck.type = "checkbox";
autoCheck.checked = !!(stage.config.Plan && stage.config.Plan.auto_approve);
autoCheck.onmousedown = (e) => e.stopPropagation();
autoCheck.onchange = () => {
if (!this.stages[index].config.Plan) this.stages[index].config = { Plan: { environment: "", auto_approve: false } };
this.stages[index].config.Plan.auto_approve = autoCheck.checked;
this._sync();
};
autoLabel.append(autoCheck, document.createTextNode("auto-approve"));
configRow.append(envLabel, envInput, autoLabel);
}
card.append(configRow);
@@ -568,6 +599,7 @@ class PipelineBuilder extends HTMLElement {
const TYPE_COLORS = {
deploy: { bg: "#dbeafe", border: "#93c5fd", text: "#1e40af" },
wait: { bg: "#fef3c7", border: "#fcd34d", text: "#92400e" },
plan: { bg: "#ede9fe", border: "#c4b5fd", text: "#5b21b6" },
};
for (const s of named) {

View File

@@ -169,3 +169,83 @@ CREATE TABLE approval_decisions (
2. Run `buf generate` in forest to regenerate gRPC interface stubs
3. Run forest tests
4. E2E test: create approval policy, trigger release, verify UI shows approval buttons
---
## Plan Stage Support (Prepare-Before-Deploy)
### Overview
Added support for "plan" pipeline stages — destinations that run a prepare/dry-run (e.g. terraform plan) and require approval of the output before the actual deploy proceeds. Forest already had full infrastructure for this; this work surfaces it in the Forage UI.
### Changes
#### forage-core (`crates/forage-core/src/platform/mod.rs`)
- Added `PipelineStageConfig::Plan { environment, auto_approve }` variant
- Added `approval_status: Option<String>` and `auto_approve: Option<bool>` to `PipelineRunStageState`
- Added 3 new `ForestPlatform` trait methods: `approve_plan_stage`, `reject_plan_stage`, `get_plan_output`
- Added `PlanOutput` struct (`plan_output: String`, `status: String`)
#### forage-server gRPC client (`forest_client.rs`)
- `convert_pipeline_stage`: handles `Plan` config variant (was previously mapped to empty Deploy)
- `convert_pipeline_stage_state`: recognizes `Plan` stage type + `AwaitingApproval` status + new fields
- `convert_stages_to_grpc`: handles `PipelineStageConfig::Plan``PlanStageConfig`
- Implemented `approve_plan_stage`, `reject_plan_stage`, `get_plan_output` calling forest's RPCs
#### forage-server routes (`routes/platform.rs`)
- Added 3 API routes:
- `POST /api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/approve`
- `POST /api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/reject`
- `GET /api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/output`
- `ApiPipelineStage` now includes `approval_status` and `auto_approve`
- `build_timeline_json`: plan stages with `AWAITING_APPROVAL` status are shown with that status; releases with plan stages awaiting approval are treated as `needs_action` (not hidden)
#### Pipeline builder (`static/js/pipeline-builder.js`)
- Added "plan" as third stage type in dropdown
- Plan stage config: environment + auto-approve checkbox
- Purple color scheme for plan nodes in DAG visualization
#### Svelte timeline (`frontend/src/ReleaseTimeline.svelte`)
- `approvePlanStage(release, stage, reject)` function for approve/reject via API
- `viewPlanOutput(release, stage)` function for on-demand plan output fetching (toggle)
- Plan stages render with purple shield icon when `AWAITING_APPROVAL`
- "Approve plan" / "Reject" buttons on plan stages awaiting approval
- "View plan" / "Hide plan" button to toggle plan output display
- Plan output shown in collapsible `<pre>` block (monospace, max-height 256px with scroll)
- Summary line shows plan stage badge + approve button when plan awaiting approval
#### Status helpers (`frontend/src/lib/status.js`)
- Added `planStageLabel(status)` function
- `pipelineSummary`: detects `AWAITING_APPROVAL` plan stages → "Awaiting plan approval" (purple)
#### Slack notifications (`forage-core/src/integrations/router.rs`)
- Plan stage rendering in Slack blocks: "Planning", "Awaiting plan approval", "Plan approved", "Plan failed"
- Shield emoji for AWAITING_APPROVAL status
#### Test support (`test_support.rs`)
- Added default mock implementations for the 3 new trait methods
### Forest Runner Infrastructure
#### Proto (`runner.proto`)
- Added `ReleaseMode` enum: `RELEASE_MODE_UNSPECIFIED`, `RELEASE_MODE_DEPLOY`, `RELEASE_MODE_PLAN`
- Added `mode` field (type `ReleaseMode`) to `WorkAssignment` — tells remote runners whether to deploy or plan
- Added `plan_output` field (optional string) to `CompleteReleaseRequest` — runners send plan output back
#### Scheduler (`scheduler.rs`)
- Reads `release_state.mode` and maps to `ReleaseMode::Plan` / `ReleaseMode::Deploy`
- Includes `mode` in `WorkAssignment` when dispatching to remote runners
#### Runner gRPC handler (`grpc/runner.rs`)
- `complete_release`: stores `plan_output` from `CompleteReleaseRequest` to `release_states.plan_output` in DB
#### Terraform destination (`destinations/terraformv1.rs`)
- `plan()`: now captures actual terraform plan stdout (not just a marker)
- Added `run_capture()` method — same as `run()` but captures stdout into a String
- Added `run_command_capture()` — like `run_command()` but returns captured stdout while still logging
#### Runner crate (`forest-runner`)
- `RunnerDestination` trait: added `plan()` method (default returns None)
- `Executor`: checks `ReleaseMode` from `WorkAssignment`, calls `plan()` instead of `release()` for plan mode
- `RunnerSession::complete_release`: accepts optional `plan_output` parameter
- `run_destination_plan()` function: prepare + plan, returns `Option<String>`

View File

@@ -79,6 +79,49 @@
</div>
{% endif %}
{# ── Deploy action ─────────────────────────────────────────── #}
{% if is_admin %}
<div class="mb-8">
<details class="border border-gray-200 rounded-lg group">
<summary class="px-4 py-3 flex items-center gap-2 text-sm cursor-pointer list-none hover:bg-gray-50">
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span class="font-medium text-gray-700">Deploy this release</span>
<svg class="w-3 h-3 text-gray-400 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>
<div class="px-4 py-4 border-t border-gray-100">
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/deploy" class="space-y-4">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="artifact_id" value="{{ artifact_id }}">
{% if has_active_pipeline %}
<div class="flex items-center gap-3 p-3 bg-purple-50 border border-purple-200 rounded-md">
<input type="checkbox" id="use-pipeline" name="use_pipeline" value="true" checked class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<label for="use-pipeline" class="text-sm text-purple-800">
Use pipeline <span class="text-purple-600 text-xs">(follows the configured multi-stage deployment pipeline)</span>
</label>
</div>
{% endif %}
<div id="env-select">
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
<select 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">
<option value="">All environments (pipeline decides)</option>
{% for env in environments %}
<option value="{{ env.name }}">{{ env.name }}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 mt-1">Leave empty when using a pipeline — it will deploy to all configured stages.</p>
</div>
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
Deploy
</button>
</form>
</div>
</details>
</div>
{% endif %}
{# ── Pipeline stages ───────────────────────────────────────── #}
{% if has_pipeline and pipeline_stages | length > 0 %}
<div class="mb-8">
@@ -95,6 +138,8 @@
<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>
{% elif stage.status == "AWAITING_APPROVAL" or (stage.stage_type == "plan" and (stage.approval_status == "AWAITINGAPPROVAL" or stage.approval_status == "AWAITING_APPROVAL")) %}
<svg class="w-4 h-4 text-purple-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-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>
{% 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 %}
@@ -112,6 +157,30 @@
{% 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>
{% elif stage.stage_type == "plan" %}
{% set plan_awaiting = stage.approval_status == "AWAITINGAPPROVAL" or stage.approval_status == "AWAITING_APPROVAL" or stage.status == "AWAITING_APPROVAL" %}
<span class="text-sm {{ 'text-purple-700' if plan_awaiting else '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" %}Plan approved{% elif plan_awaiting %}Awaiting plan approval{% elif stage.status == "RUNNING" %}Planning{% elif stage.status == "FAILED" %}Plan failed{% elif stage.status == "CANCELLED" %}Plan cancelled{% else %}Plan{% 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>
{% if plan_awaiting and release_intent_id %}
<div class="flex items-center gap-2 ml-auto">
<form method="post" action="/api/orgs/{{ org_name }}/projects/{{ project_name }}/plan-stages/{{ stage.id }}/approve" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="redirect_to" value="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors">Approve plan</button>
</form>
<form method="post" action="/api/orgs/{{ org_name }}/projects/{{ project_name }}/plan-stages/{{ stage.id }}/reject" class="inline" onsubmit="return confirm('Reject this plan?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="redirect_to" value="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md border border-red-300 text-red-600 hover:bg-red-50 transition-colors">Reject</button>
</form>
</div>
{% endif %}
{% endif %}
{# Elapsed time #}
@@ -146,17 +215,39 @@
{# ── Policy evaluations (approval, soak, branch) ──────────── #}
{% if policy_evaluations | length > 0 %}
{% set pns = namespace(passed=0, total=0, pending=0) %}
{% for eval in policy_evaluations %}
{% set pns.total = pns.total + 1 %}
{% if eval.passed %}
{% set pns.passed = pns.passed + 1 %}
{% else %}
{% set pns.pending = pns.pending + 1 %}
{% endif %}
{% endfor %}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-900 mb-3">Policy Requirements</h2>
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
<div class="flex items-center gap-3 mb-3">
<h2 class="text-sm font-semibold text-gray-900">Policy Requirements</h2>
{% if pns.passed == pns.total %}
<span class="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 px-2 py-0.5 rounded-full">
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ pns.passed }}/{{ pns.total }} passed
</span>
{% else %}
<span class="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 px-2 py-0.5 rounded-full">
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ pns.passed }}/{{ pns.total }} passed
</span>
{% endif %}
</div>
{# ── Pending / failed policies (expanded) ──────────────── #}
{% if pns.pending > 0 %}
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100 mb-3">
{% for eval in policy_evaluations %}
{% if not eval.passed %}
<div class="px-4 py-3">
<div class="flex items-center gap-3 text-sm">
{% if eval.passed %}
<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 %}
<svg class="w-4 h-4 text-amber-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% endif %}
{% if eval.policy_type == "approval" %}
<span class="bg-emerald-100 text-emerald-700 text-xs font-medium px-1.5 py-0.5 rounded">Approval</span>
@@ -196,42 +287,73 @@
</div>
{% endif %}
{% if not eval.passed %}
{% if is_release_author and not is_admin %}
<p class="text-xs text-gray-500 italic">You cannot approve your own release.</p>
{% else %}
<div class="flex items-center gap-2">
{% if not is_release_author %}
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/approve" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors">Approve</button>
</form>
{% endif %}
{% if is_release_author and is_admin %}
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/approve" class="inline" onsubmit="return confirm('You are the release author. This is an admin bypass — are you sure?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
<input type="hidden" name="force_bypass" value="true">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors">Bypass (Admin)</button>
</form>
{% endif %}
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/reject" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md border border-red-300 text-red-600 hover:bg-red-50 transition-colors">Reject</button>
</form>
</div>
{% if is_release_author and not is_admin %}
<p class="text-xs text-gray-500 italic">You cannot approve your own release.</p>
{% else %}
<div class="flex items-center gap-2">
{% if not is_release_author %}
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/approve" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors">Approve</button>
</form>
{% endif %}
{% if is_admin %}
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/approve" class="inline" onsubmit="return confirm('Admin bypass — skip remaining approvals?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
<input type="hidden" name="force_bypass" value="true">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors">Bypass (Admin)</button>
</form>
{% endif %}
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/reject" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md border border-red-300 text-red-600 hover:bg-red-50 transition-colors">Reject</button>
</form>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{# ── Passed policies (collapsed) ───────────────────────── #}
{% if pns.passed > 0 %}
<details class="border border-gray-200 rounded-lg group">
<summary class="px-4 py-2.5 flex items-center gap-2 text-sm text-gray-500 cursor-pointer list-none hover:bg-gray-50">
<svg class="w-3 h-3 text-gray-400 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>
<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>
{{ pns.passed }} passed polic{{ "y" if pns.passed == 1 else "ies" }}
</summary>
<div class="divide-y divide-gray-100 border-t border-gray-100">
{% for eval in policy_evaluations %}
{% if eval.passed %}
<div class="px-4 py-2.5 flex items-center gap-3 text-sm">
<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>
{% if eval.policy_type == "approval" %}
<span class="bg-emerald-100 text-emerald-700 text-xs font-medium px-1.5 py-0.5 rounded">Approval</span>
{% elif eval.policy_type == "soak_time" %}
<span class="bg-indigo-100 text-indigo-700 text-xs font-medium px-1.5 py-0.5 rounded">Soak Time</span>
{% elif eval.policy_type == "branch_restriction" %}
<span class="bg-orange-100 text-orange-700 text-xs font-medium px-1.5 py-0.5 rounded">Branch</span>
{% endif %}
<span class="text-gray-600">{{ eval.policy_name }}</span>
<span class="text-xs text-gray-400 ml-auto">{{ eval.reason }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</details>
{% endif %}
</div>
{% endif %}