mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat: add Gateway page and Runs page with real-time updates
- Implemented a new Gateway page that provides a uniform ingress for deployed agents. - Created a Runs page that lists recent pipeline executions with auto-refresh and error handling. - Added a RunsListener component to handle real-time notifications for new runs via SSE. - Updated the AppSidebar to include links to the new Gateway and Runs pages. - Enhanced FlowNode component to improve status display and styling. - Introduced EmptySection component for consistent empty state presentation. - Updated API utility to include a new endpoint for the runs stream.
This commit is contained in:
@@ -2,6 +2,26 @@
|
|||||||
|
|
||||||
BINARY := bin/flow
|
BINARY := bin/flow
|
||||||
|
|
||||||
|
# --- env defaults for `make watch` ----------------------------------------
|
||||||
|
# These match the host-side ports published by docker-compose.yml. Override
|
||||||
|
# any value at the command line, e.g. `make watch MINIO_ENDPOINT=...`.
|
||||||
|
#
|
||||||
|
# Anything you `export` separately in your shell still takes precedence over
|
||||||
|
# these, since the Go binary only reads its env via os.Getenv.
|
||||||
|
export FLOW_ADDR ?= :8090
|
||||||
|
export FLOW_CORS_ORIGINS ?= http://localhost:3000
|
||||||
|
export MONGO_URI ?= mongodb://localhost:27017
|
||||||
|
export MONGO_DB ?= flow
|
||||||
|
export RESTATE_INGRESS_URL ?= http://localhost:8081
|
||||||
|
export RESTATE_ADMIN_URL ?= http://localhost:9070
|
||||||
|
export RESTATE_DEPLOYMENT_URI ?= http://host.docker.internal:9080
|
||||||
|
export BUILDKIT_HOST ?= tcp://127.0.0.1:1234
|
||||||
|
export MINIO_ENDPOINT ?= 127.0.0.1:9000
|
||||||
|
export MINIO_ACCESS_KEY ?= minio
|
||||||
|
export MINIO_SECRET_KEY ?= minio12345
|
||||||
|
export MINIO_BUCKET ?= flow-logs
|
||||||
|
export MINIO_USE_SSL ?= false
|
||||||
|
|
||||||
build: web
|
build: web
|
||||||
go build -o $(BINARY) ./cmd/flow
|
go build -o $(BINARY) ./cmd/flow
|
||||||
|
|
||||||
@@ -16,7 +36,7 @@ web-dev:
|
|||||||
cd web && (test -d node_modules || npm install --no-fund --no-audit --loglevel=error) && npm run dev
|
cd web && (test -d node_modules || npm install --no-fund --no-audit --loglevel=error) && npm run dev
|
||||||
|
|
||||||
# `make dev` runs the Next.js dev server (auto-installs deps).
|
# `make dev` runs the Next.js dev server (auto-installs deps).
|
||||||
# In another terminal run `make watch` to hot-reload the Go API on :8080;
|
# In another terminal run `make watch` to hot-reload the Go API on :8090;
|
||||||
# the Next dev rewrite forwards /api/* requests to it.
|
# the Next dev rewrite forwards /api/* requests to it.
|
||||||
dev: web-dev
|
dev: web-dev
|
||||||
|
|
||||||
@@ -24,12 +44,20 @@ serve: build-go
|
|||||||
./$(BINARY) serve
|
./$(BINARY) serve
|
||||||
|
|
||||||
# Hot-reload the Go server with air. Re-run on every *.go change.
|
# Hot-reload the Go server with air. Re-run on every *.go change.
|
||||||
|
# Picks up MINIO_*, MONGO_*, RESTATE_*, BUILDKIT_HOST, FLOW_* defaults from
|
||||||
|
# this Makefile so a fresh shell can `make watch` without exporting anything.
|
||||||
# First time: `go install github.com/air-verse/air@latest`
|
# First time: `go install github.com/air-verse/air@latest`
|
||||||
watch:
|
watch:
|
||||||
@command -v air >/dev/null 2>&1 || { \
|
@command -v air >/dev/null 2>&1 || { \
|
||||||
echo "installing air..."; \
|
echo "installing air..."; \
|
||||||
go install github.com/air-verse/air@latest; \
|
go install github.com/air-verse/air@latest; \
|
||||||
}
|
}
|
||||||
|
@echo "flow env:"
|
||||||
|
@echo " FLOW_ADDR = $(FLOW_ADDR)"
|
||||||
|
@echo " MONGO_URI = $(MONGO_URI)"
|
||||||
|
@echo " RESTATE_INGRESS_URL = $(RESTATE_INGRESS_URL)"
|
||||||
|
@echo " BUILDKIT_HOST = $(BUILDKIT_HOST)"
|
||||||
|
@echo " MINIO_ENDPOINT = $(MINIO_ENDPOINT)"
|
||||||
air
|
air
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
|
|||||||
@@ -50,26 +50,20 @@ Pre-v0.1. Working but moving fast — APIs and node types may change.
|
|||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
The default `docker compose up` brings the **API + UI + backing stores**
|
|
||||||
(Mongo, Restate). It assumes you have BuildKit + a registry running on
|
|
||||||
the host already (e.g. via the sibling `langship` stack).
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up
|
docker compose up
|
||||||
# UI: http://localhost:3000
|
# UI: http://localhost:3000
|
||||||
# API: http://localhost:8090
|
# API: http://localhost:8090
|
||||||
# Restate: :8081 ingress, :9070 admin
|
# Restate: :8081 ingress, :9070 admin
|
||||||
|
# BuildKit: 127.0.0.1:1234
|
||||||
|
# Registry: 127.0.0.1:5050 (host port; buildkitd pushes to registry:5000 internally)
|
||||||
|
# MinIO: 127.0.0.1:9000 (S3 API), :9001 (console; minio / minio12345)
|
||||||
```
|
```
|
||||||
|
|
||||||
If you **don't** have BuildKit + registry running, layer the standalone
|
The base compose now bundles every service flow needs: **mongo, restate,
|
||||||
overlay to bring them up too:
|
buildkitd, registry, minio, flow, web**. If a sibling stack already owns
|
||||||
|
one of those host ports (e.g. another langship-* set), stop that
|
||||||
```sh
|
container or override the port mapping in a `compose.override.yml`.
|
||||||
docker compose -f docker-compose.yml -f docker-compose.standalone.yml up
|
|
||||||
# adds:
|
|
||||||
# buildkitd 127.0.0.1:1234 (moby/buildkit:v0.18.2)
|
|
||||||
# registry 127.0.0.1:5000 (registry:2)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dev (hot reload)
|
## Dev (hot reload)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/lyzrai/flow/pkg/engine"
|
"github.com/lyzrai/flow/pkg/engine"
|
||||||
"github.com/lyzrai/flow/pkg/execevents"
|
"github.com/lyzrai/flow/pkg/execevents"
|
||||||
"github.com/lyzrai/flow/pkg/executors"
|
"github.com/lyzrai/flow/pkg/executors"
|
||||||
|
"github.com/lyzrai/flow/pkg/logstore"
|
||||||
"github.com/lyzrai/flow/pkg/orchestrator"
|
"github.com/lyzrai/flow/pkg/orchestrator"
|
||||||
"github.com/lyzrai/flow/pkg/storage"
|
"github.com/lyzrai/flow/pkg/storage"
|
||||||
)
|
)
|
||||||
@@ -132,6 +133,33 @@ func serve() int {
|
|||||||
}()
|
}()
|
||||||
slog.Info("mongo connected", slog.String("db", mongoDB))
|
slog.Info("mongo connected", slog.String("db", mongoDB))
|
||||||
|
|
||||||
|
// MinIO is optional — when MINIO_ENDPOINT is unset we skip log
|
||||||
|
// archiving. Live SSE log streaming still works regardless.
|
||||||
|
var logs logstore.Store
|
||||||
|
if endpoint := envOr("MINIO_ENDPOINT", ""); endpoint != "" {
|
||||||
|
minioCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
m, err := logstore.NewMinio(minioCtx, logstore.Config{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
AccessKey: envOr("MINIO_ACCESS_KEY", "minio"),
|
||||||
|
SecretKey: envOr("MINIO_SECRET_KEY", "minio12345"),
|
||||||
|
Bucket: envOr("MINIO_BUCKET", "flow-logs"),
|
||||||
|
UseSSL: envOr("MINIO_USE_SSL", "false") == "true",
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("minio init failed, log archive disabled",
|
||||||
|
slog.String("endpoint", endpoint),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logs = m
|
||||||
|
slog.Info("minio connected",
|
||||||
|
slog.String("endpoint", endpoint),
|
||||||
|
slog.String("bucket", envOr("MINIO_BUCKET", "flow-logs")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Register executors now that storage is ready; the Build executor
|
// Register executors now that storage is ready; the Build executor
|
||||||
// reads from AgentStore.
|
// reads from AgentStore.
|
||||||
executors.RegisterAll(executors.RegistryDeps{
|
executors.RegisterAll(executors.RegistryDeps{
|
||||||
@@ -189,6 +217,7 @@ func serve() int {
|
|||||||
Runs: mongo.Runs(),
|
Runs: mongo.Runs(),
|
||||||
Agents: mongo.Agents(),
|
Agents: mongo.Agents(),
|
||||||
Events: eventBus,
|
Events: eventBus,
|
||||||
|
Logs: logs,
|
||||||
}),
|
}),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
# Standalone overlay — adds buildkitd + registry to the base
|
|
||||||
# docker-compose.yml when you DON'T have the sibling `langship` stack
|
|
||||||
# already providing them on the host (1234 + 5000).
|
|
||||||
#
|
|
||||||
# Use:
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.standalone.yml up
|
|
||||||
#
|
|
||||||
# This re-binds `flow` to point BUILDKIT_HOST at the in-network buildkitd
|
|
||||||
# instead of host.docker.internal, and adds explicit dependencies.
|
|
||||||
|
|
||||||
services:
|
|
||||||
registry:
|
|
||||||
image: registry:2
|
|
||||||
container_name: langship-flow-registry
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:5000:5000"
|
|
||||||
volumes:
|
|
||||||
- registry-data:/var/lib/registry
|
|
||||||
|
|
||||||
buildkitd:
|
|
||||||
image: moby/buildkit:v0.18.2
|
|
||||||
container_name: langship-flow-buildkitd
|
|
||||||
privileged: true
|
|
||||||
command:
|
|
||||||
- --addr
|
|
||||||
- tcp://0.0.0.0:1234
|
|
||||||
- --addr
|
|
||||||
- unix:///run/buildkit/buildkitd.sock
|
|
||||||
- --config
|
|
||||||
- /etc/buildkit/buildkitd.toml
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:1234:1234"
|
|
||||||
volumes:
|
|
||||||
- buildkit-data:/var/lib/buildkit
|
|
||||||
- ./docker/buildkit/buildkitd.toml:/etc/buildkit/buildkitd.toml:ro
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "buildctl", "--addr", "tcp://0.0.0.0:1234", "debug", "info"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
flow:
|
|
||||||
environment:
|
|
||||||
# In standalone mode, buildkitd is a sibling on the same compose network.
|
|
||||||
- BUILDKIT_HOST=tcp://buildkitd:1234
|
|
||||||
depends_on:
|
|
||||||
- mongo
|
|
||||||
- restate
|
|
||||||
- buildkitd
|
|
||||||
- registry
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
buildkit-data:
|
|
||||||
registry-data:
|
|
||||||
+63
-11
@@ -12,12 +12,57 @@ services:
|
|||||||
- "8081:8080" # ingress
|
- "8081:8080" # ingress
|
||||||
- "9070:9070" # admin
|
- "9070:9070" # admin
|
||||||
|
|
||||||
# NOTE: this stack reuses the buildkitd + registry from the sibling
|
registry:
|
||||||
# `langship` compose project — both already publish on
|
image: registry:2
|
||||||
# 127.0.0.1:1234 (BuildKit) and 127.0.0.1:5000 (registry). Bringing them
|
container_name: langship-flow-registry
|
||||||
# up here would collide on the same host ports. If you ever run flow
|
ports:
|
||||||
# standalone (without that other stack), uncomment the services in
|
# Host port 5050 (debug / docker pull from host); inside the compose
|
||||||
# docker-compose.standalone.yml.
|
# network buildkitd pushes to registry:5000 via service DNS.
|
||||||
|
- "127.0.0.1:5050:5000"
|
||||||
|
volumes:
|
||||||
|
- registry-data:/var/lib/registry
|
||||||
|
|
||||||
|
buildkitd:
|
||||||
|
image: moby/buildkit:v0.18.2
|
||||||
|
container_name: langship-flow-buildkitd
|
||||||
|
privileged: true
|
||||||
|
command:
|
||||||
|
- --addr
|
||||||
|
- tcp://0.0.0.0:1234
|
||||||
|
- --addr
|
||||||
|
- unix:///run/buildkit/buildkitd.sock
|
||||||
|
- --config
|
||||||
|
- /etc/buildkit/buildkitd.toml
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:1234:1234"
|
||||||
|
volumes:
|
||||||
|
- buildkit-data:/var/lib/buildkit
|
||||||
|
- ./docker/buildkit/buildkitd.toml:/etc/buildkit/buildkitd.toml:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "buildctl", "--addr", "tcp://0.0.0.0:1234", "debug", "info"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: langship-flow-minio
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=minio
|
||||||
|
- MINIO_ROOT_PASSWORD=minio12345
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9000:9000" # S3 API
|
||||||
|
- "127.0.0.1:9001:9001" # web console
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
flow:
|
flow:
|
||||||
build: .
|
build: .
|
||||||
@@ -32,14 +77,18 @@ services:
|
|||||||
- RESTATE_INGRESS_URL=http://restate:8080
|
- RESTATE_INGRESS_URL=http://restate:8080
|
||||||
- RESTATE_ADMIN_URL=http://restate:9070
|
- RESTATE_ADMIN_URL=http://restate:9070
|
||||||
- RESTATE_DEPLOYMENT_URI=http://flow:9080
|
- RESTATE_DEPLOYMENT_URI=http://flow:9080
|
||||||
- BUILDKIT_HOST=tcp://host.docker.internal:1234
|
- BUILDKIT_HOST=tcp://buildkitd:1234
|
||||||
|
- MINIO_ENDPOINT=minio:9000
|
||||||
|
- MINIO_ACCESS_KEY=minio
|
||||||
|
- MINIO_SECRET_KEY=minio12345
|
||||||
|
- MINIO_BUCKET=flow-logs
|
||||||
|
- MINIO_USE_SSL=false
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongo
|
- mongo
|
||||||
- restate
|
- restate
|
||||||
# buildkitd + registry are external (the sibling langship stack); see
|
- buildkitd
|
||||||
# the note above. Add `external_links: ["langship-buildkitd:buildkitd",
|
- registry
|
||||||
# "langship-registry:registry"]` if you run flow as docker-compose and
|
- minio
|
||||||
# need network connectivity to those containers.
|
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: ./web
|
build: ./web
|
||||||
@@ -50,3 +99,6 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongo-data:
|
mongo-data:
|
||||||
|
buildkit-data:
|
||||||
|
registry-data:
|
||||||
|
minio-data:
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
module github.com/lyzrai/flow
|
module github.com/lyzrai/flow
|
||||||
|
|
||||||
go 1.24.5
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/docker/cli v27.4.0-rc.2+incompatible
|
github.com/docker/cli v27.4.0-rc.2+incompatible
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/minio/minio-go/v7 v7.1.0
|
||||||
github.com/moby/buildkit v0.18.2
|
github.com/moby/buildkit v0.18.2
|
||||||
github.com/restatedev/sdk-go v0.23.0
|
github.com/restatedev/sdk-go v0.23.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0
|
go.mongodb.org/mongo-driver/v2 v2.6.0
|
||||||
golang.org/x/sync v0.18.0
|
golang.org/x/sync v0.19.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -17,6 +18,7 @@ require (
|
|||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/buger/jsonparser v1.1.1 // indirect
|
github.com/buger/jsonparser v1.1.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/containerd/console v1.0.4 // indirect
|
github.com/containerd/console v1.0.4 // indirect
|
||||||
github.com/containerd/containerd v1.7.24 // indirect
|
github.com/containerd/containerd v1.7.24 // indirect
|
||||||
github.com/containerd/containerd/api v1.8.0 // indirect
|
github.com/containerd/containerd/api v1.8.0 // indirect
|
||||||
@@ -30,7 +32,9 @@ require (
|
|||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
@@ -47,8 +51,12 @@ require (
|
|||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/in-toto/in-toto-golang v0.5.0 // indirect
|
github.com/in-toto/in-toto-golang v0.5.0 // indirect
|
||||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||||
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/locker v1.0.1 // indirect
|
github.com/moby/locker v1.0.1 // indirect
|
||||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
@@ -57,12 +65,15 @@ require (
|
|||||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
||||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/tetratelabs/wazero v1.9.0 // indirect
|
github.com/tetratelabs/wazero v1.9.0 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
github.com/tonistiigi/fsutil v0.0.0-20241121093142-31cf1f437184 // indirect
|
github.com/tonistiigi/fsutil v0.0.0-20241121093142-31cf1f437184 // indirect
|
||||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
|
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
|
||||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
||||||
@@ -72,6 +83,7 @@ require (
|
|||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
|
||||||
@@ -82,10 +94,11 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.12.0 // indirect
|
golang.org/x/time v0.12.0 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect
|
google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn
|
|||||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
||||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
|
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
|
||||||
@@ -73,10 +75,14 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
|||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -118,14 +124,25 @@ github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uO
|
|||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||||
|
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
|
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
|
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
|
||||||
|
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
|
||||||
github.com/moby/buildkit v0.18.2 h1:l86uBvxh4ntNoUUg3Y0eGTbKg1PbUh6tawJ4Xt75SpQ=
|
github.com/moby/buildkit v0.18.2 h1:l86uBvxh4ntNoUUg3Y0eGTbKg1PbUh6tawJ4Xt75SpQ=
|
||||||
github.com/moby/buildkit v0.18.2/go.mod h1:vCR5CX8NGsPTthTg681+9kdmfvkvqJBXEv71GZe5msU=
|
github.com/moby/buildkit v0.18.2/go.mod h1:vCR5CX8NGsPTthTg681+9kdmfvkvqJBXEv71GZe5msU=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
@@ -160,6 +177,8 @@ github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaL
|
|||||||
github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec=
|
github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec=
|
||||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||||
@@ -172,6 +191,8 @@ github.com/restatedev/sdk-go v0.23.0 h1:Eewh6n/YZUfA7In5ZiAIA6smLyssynsBHVijUguN
|
|||||||
github.com/restatedev/sdk-go v0.23.0/go.mod h1:2G757yGe0Ihwcb+Z/HZUscQ0g3PFTyueO0f8qlqxWDo=
|
github.com/restatedev/sdk-go v0.23.0/go.mod h1:2G757yGe0Ihwcb+Z/HZUscQ0g3PFTyueO0f8qlqxWDo=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 h1:b23VGrQhTA8cN2CbBw7/FulN9fTtqYUdS5+Oxzt+DUE=
|
github.com/secure-systems-lab/go-securesystemslib v0.4.0 h1:b23VGrQhTA8cN2CbBw7/FulN9fTtqYUdS5+Oxzt+DUE=
|
||||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0/go.mod h1:FGBZgq2tXWICsxWQW1msNf49F0Pf2Op5Htayx335Qbs=
|
github.com/secure-systems-lab/go-securesystemslib v0.4.0/go.mod h1:FGBZgq2tXWICsxWQW1msNf49F0Pf2Op5Htayx335Qbs=
|
||||||
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
|
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
|
||||||
@@ -187,6 +208,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||||
|
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||||
|
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/tonistiigi/fsutil v0.0.0-20241121093142-31cf1f437184 h1:RgyoSI38Y36zjQaszel/0RAcIehAnjA1B0RiUV9SDO4=
|
github.com/tonistiigi/fsutil v0.0.0-20241121093142-31cf1f437184 h1:RgyoSI38Y36zjQaszel/0RAcIehAnjA1B0RiUV9SDO4=
|
||||||
github.com/tonistiigi/fsutil v0.0.0-20241121093142-31cf1f437184/go.mod h1:Dl/9oEjK7IqnjAm21Okx/XIxUCFJzvh+XdVHUlBwXTw=
|
github.com/tonistiigi/fsutil v0.0.0-20241121093142-31cf1f437184/go.mod h1:Dl/9oEjK7IqnjAm21Okx/XIxUCFJzvh+XdVHUlBwXTw=
|
||||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 h1:7I5c2Ig/5FgqkYOh/N87NzoyI9U15qUPXhDD8uCupv8=
|
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 h1:7I5c2Ig/5FgqkYOh/N87NzoyI9U15qUPXhDD8uCupv8=
|
||||||
@@ -210,6 +233,10 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
|||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
|
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
@@ -238,33 +265,35 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
|
|||||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -274,18 +303,18 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -434,6 +434,28 @@ func (s *Server) dispatchAgent(ctx context.Context, a *storage.Agent, trigger an
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Hook the log archiver onto the new execution so per-node lines
|
||||||
|
// land in MinIO when each node finishes.
|
||||||
|
startLogArchiver(context.Background(), s.logs, s.events, execID)
|
||||||
|
|
||||||
|
// Broadcast so /runs etc. light up without polling. We extract
|
||||||
|
// `source` from the trigger payload (manual / github_push).
|
||||||
|
source := ""
|
||||||
|
if items, ok := trigger.([]map[string]any); ok && len(items) > 0 {
|
||||||
|
if s, _ := items[0]["source"].(string); s != "" {
|
||||||
|
source = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.runsBus.Publish(RunCreatedEvent{
|
||||||
|
Type: "run_created",
|
||||||
|
ExecutionID: execID,
|
||||||
|
PipelineID: pid,
|
||||||
|
PipelineName: p.Name,
|
||||||
|
AgentID: a.ID,
|
||||||
|
Source: source,
|
||||||
|
StartedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
|
||||||
if s.runs != nil {
|
if s.runs != nil {
|
||||||
_ = s.runs.Insert(ctx, &storage.Run{
|
_ = s.runs.Insert(ctx, &storage.Run{
|
||||||
ID: execID,
|
ID: execID,
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lyzrai/flow/pkg/engine"
|
||||||
|
"github.com/lyzrai/flow/pkg/logstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logArchiver subscribes to the in-memory event bus for a single execution,
|
||||||
|
// buffers `node_log` lines per node, and flushes each node's buffer to the
|
||||||
|
// log store when the node finishes (node_completed / node_error). On the
|
||||||
|
// terminal `done` event it flushes any leftovers and stops.
|
||||||
|
//
|
||||||
|
// One archiver per active execution. They drop themselves from the
|
||||||
|
// archive map when finished.
|
||||||
|
type logArchiver struct {
|
||||||
|
execID string
|
||||||
|
store logstore.Store
|
||||||
|
bus EventSubscriber
|
||||||
|
|
||||||
|
// in-memory buffer per node — the live SSE listener (the UI) reads from
|
||||||
|
// the bus directly; this struct is purely for archive-on-finish.
|
||||||
|
mu sync.Mutex
|
||||||
|
buffers map[string]*bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// startLogArchiver spins up a goroutine that drains the per-exec event bus
|
||||||
|
// into MinIO. Safe to call once per execution; idempotent if the bus is nil.
|
||||||
|
func startLogArchiver(parentCtx context.Context, store logstore.Store, bus EventSubscriber, execID string) {
|
||||||
|
if store == nil || bus == nil || execID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ar := &logArchiver{
|
||||||
|
execID: execID,
|
||||||
|
store: store,
|
||||||
|
bus: bus,
|
||||||
|
buffers: map[string]*bytes.Buffer{},
|
||||||
|
}
|
||||||
|
go ar.run(parentCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *logArchiver) run(parentCtx context.Context) {
|
||||||
|
ch, cancel := a.bus.Subscribe(a.execID)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Detach from the request context that triggered the run; once submitted
|
||||||
|
// we want to keep archiving even if the caller disconnects. Cap with a
|
||||||
|
// per-run deadline so a stuck workflow doesn't leak this goroutine
|
||||||
|
// forever.
|
||||||
|
ctx, cancelCtx := context.WithTimeout(context.Background(), 1*time.Hour)
|
||||||
|
defer cancelCtx()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
a.flushAll(ctx)
|
||||||
|
return
|
||||||
|
case <-parentCtx.Done():
|
||||||
|
// Process is shutting down.
|
||||||
|
a.flushAll(context.Background())
|
||||||
|
return
|
||||||
|
case ev, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
a.flushAll(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.handle(ctx, ev)
|
||||||
|
if ev.Type == engine.EventDone {
|
||||||
|
a.flushAll(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *logArchiver) handle(ctx context.Context, ev engine.ExecutionEvent) {
|
||||||
|
switch ev.Type {
|
||||||
|
case engine.EventNodeLog:
|
||||||
|
if ev.Node == "" || ev.Content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
buf := a.buffers[ev.Node]
|
||||||
|
if buf == nil {
|
||||||
|
buf = &bytes.Buffer{}
|
||||||
|
a.buffers[ev.Node] = buf
|
||||||
|
}
|
||||||
|
// One line per Log event; force a trailing newline so the archived
|
||||||
|
// file is line-oriented and easy to tail.
|
||||||
|
buf.WriteString(strings.TrimRight(ev.Content, "\r\n"))
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
case engine.EventNodeCompleted, engine.EventNodeError:
|
||||||
|
if ev.Node == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.flushNode(ctx, ev.Node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *logArchiver) flushNode(ctx context.Context, node string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
buf := a.buffers[node]
|
||||||
|
if buf == nil || buf.Len() == 0 {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := append([]byte(nil), buf.Bytes()...)
|
||||||
|
delete(a.buffers, node)
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
putCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := a.store.Put(putCtx, a.execID, node, data); err != nil {
|
||||||
|
slog.WarnContext(ctx, "log_archive_failed",
|
||||||
|
slog.String("execution_id", a.execID),
|
||||||
|
slog.String("node", node),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *logArchiver) flushAll(ctx context.Context) {
|
||||||
|
a.mu.Lock()
|
||||||
|
nodes := make([]string, 0, len(a.buffers))
|
||||||
|
for n := range a.buffers {
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
for _, n := range nodes {
|
||||||
|
a.flushNode(ctx, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunCreatedEvent is the payload broadcast on /api/runs/stream whenever a
|
||||||
|
// new run is dispatched (manual API call or GitHub webhook).
|
||||||
|
type RunCreatedEvent struct {
|
||||||
|
Type string `json:"type"` // "run_created"
|
||||||
|
ExecutionID string `json:"executionId"`
|
||||||
|
PipelineID string `json:"pipelineId,omitempty"`
|
||||||
|
PipelineName string `json:"pipelineName,omitempty"`
|
||||||
|
AgentID string `json:"agentId,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"` // "manual" | "github_push"
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// runsBus is a tiny broadcast bus for cross-execution UI events. The SSE
|
||||||
|
// stream handler subscribes; dispatchers publish. All in-memory; one bus
|
||||||
|
// per process.
|
||||||
|
type runsBus struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
subscribers map[chan RunCreatedEvent]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunsBus() *runsBus {
|
||||||
|
return &runsBus{subscribers: map[chan RunCreatedEvent]struct{}{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish fans an event out to every active subscriber. Non-blocking — slow
|
||||||
|
// subscribers drop the event so dispatchers never stall.
|
||||||
|
func (b *runsBus) Publish(ev RunCreatedEvent) {
|
||||||
|
b.mu.RLock()
|
||||||
|
subs := make([]chan RunCreatedEvent, 0, len(b.subscribers))
|
||||||
|
for c := range b.subscribers {
|
||||||
|
subs = append(subs, c)
|
||||||
|
}
|
||||||
|
b.mu.RUnlock()
|
||||||
|
for _, c := range subs {
|
||||||
|
select {
|
||||||
|
case c <- ev:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers a buffered channel and returns it + an unsubscribe
|
||||||
|
// func that closes the channel exactly once.
|
||||||
|
func (b *runsBus) Subscribe() (<-chan RunCreatedEvent, func()) {
|
||||||
|
ch := make(chan RunCreatedEvent, 16)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.subscribers[ch] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
var once sync.Once
|
||||||
|
cancel := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.subscribers, ch)
|
||||||
|
b.mu.Unlock()
|
||||||
|
close(ch)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ch, cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshal is a small helper so handlers don't import encoding/json just for
|
||||||
|
// the event payload format.
|
||||||
|
func (e RunCreatedEvent) marshal() ([]byte, error) { return json.Marshal(e) }
|
||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lyzrai/flow/pkg/engine"
|
"github.com/lyzrai/flow/pkg/engine"
|
||||||
|
"github.com/lyzrai/flow/pkg/logstore"
|
||||||
"github.com/lyzrai/flow/pkg/models"
|
"github.com/lyzrai/flow/pkg/models"
|
||||||
"github.com/lyzrai/flow/pkg/orchestrator"
|
"github.com/lyzrai/flow/pkg/orchestrator"
|
||||||
"github.com/lyzrai/flow/pkg/storage"
|
"github.com/lyzrai/flow/pkg/storage"
|
||||||
@@ -56,6 +57,11 @@ type ServerDeps struct {
|
|||||||
// per-node lifecycle events to. The SSE handler subscribes per
|
// per-node lifecycle events to. The SSE handler subscribes per
|
||||||
// execution ID. Nil disables /api/executions/{id}/stream.
|
// execution ID. Nil disables /api/executions/{id}/stream.
|
||||||
Events EventSubscriber
|
Events EventSubscriber
|
||||||
|
|
||||||
|
// Logs is the archive backend (MinIO/S3). When set, every dispatched
|
||||||
|
// run starts a background archiver that flushes per-node log buffers
|
||||||
|
// to object storage. Nil disables archiving (live SSE still works).
|
||||||
|
Logs logstore.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventSubscriber is the slice of execevents.MemoryBus the API needs.
|
// EventSubscriber is the slice of execevents.MemoryBus the API needs.
|
||||||
@@ -77,6 +83,12 @@ type Server struct {
|
|||||||
runs storage.RunStore
|
runs storage.RunStore
|
||||||
agents storage.AgentStore
|
agents storage.AgentStore
|
||||||
events EventSubscriber
|
events EventSubscriber
|
||||||
|
logs logstore.Store
|
||||||
|
|
||||||
|
// runsBus broadcasts run_created events to every UI tab subscribed to
|
||||||
|
// /api/runs/stream. Used so a webhook-triggered run shows up live in
|
||||||
|
// the dashboard / runs list / agent detail page without polling.
|
||||||
|
runsBus *runsBus
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs an API-only Server. deps.Orchestrator may be nil —
|
// NewServer constructs an API-only Server. deps.Orchestrator may be nil —
|
||||||
@@ -93,6 +105,8 @@ func NewServer(deps ServerDeps) *Server {
|
|||||||
runs: deps.Runs,
|
runs: deps.Runs,
|
||||||
agents: deps.Agents,
|
agents: deps.Agents,
|
||||||
events: deps.Events,
|
events: deps.Events,
|
||||||
|
logs: deps.Logs,
|
||||||
|
runsBus: newRunsBus(),
|
||||||
}
|
}
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
@@ -119,8 +133,10 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("DELETE /api/workflows/{id}", s.handleDeleteFlow)
|
s.mux.HandleFunc("DELETE /api/workflows/{id}", s.handleDeleteFlow)
|
||||||
s.mux.HandleFunc("POST /api/workflows/execute", s.handleExecuteWorkflow)
|
s.mux.HandleFunc("POST /api/workflows/execute", s.handleExecuteWorkflow)
|
||||||
s.mux.HandleFunc("GET /api/executions", s.handleListExecutions)
|
s.mux.HandleFunc("GET /api/executions", s.handleListExecutions)
|
||||||
|
s.mux.HandleFunc("GET /api/runs/stream", s.handleRunsStream)
|
||||||
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
|
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
|
||||||
s.mux.HandleFunc("GET /api/executions/{id}/stream", s.handleStreamExecution)
|
s.mux.HandleFunc("GET /api/executions/{id}/stream", s.handleStreamExecution)
|
||||||
|
s.mux.HandleFunc("GET /api/executions/{id}/logs/{node}", s.handleNodeLog)
|
||||||
s.mux.HandleFunc("POST /api/executions/{id}/resume", s.handleResumeExecution)
|
s.mux.HandleFunc("POST /api/executions/{id}/resume", s.handleResumeExecution)
|
||||||
|
|
||||||
// Agents — Langship-style agent registry (git URL + PAT)
|
// Agents — Langship-style agent registry (git URL + PAT)
|
||||||
@@ -362,6 +378,21 @@ func (s *Server) handleExecuteWorkflow(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscribe the log archiver to this execution. Drains node_log events
|
||||||
|
// off the event bus and flushes per-node buffers to MinIO when each
|
||||||
|
// node completes.
|
||||||
|
startLogArchiver(context.Background(), s.logs, s.events, execID)
|
||||||
|
|
||||||
|
// Broadcast so /runs and other tabs flip to live without polling.
|
||||||
|
s.runsBus.Publish(RunCreatedEvent{
|
||||||
|
Type: "run_created",
|
||||||
|
ExecutionID: execID,
|
||||||
|
PipelineID: pipelineID,
|
||||||
|
PipelineName: pipelineName,
|
||||||
|
Source: "manual",
|
||||||
|
StartedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
|
||||||
// Best-effort run record. A failure here shouldn't block the response —
|
// Best-effort run record. A failure here shouldn't block the response —
|
||||||
// the orchestrator already accepted the workflow.
|
// the orchestrator already accepted the workflow.
|
||||||
if s.runs != nil {
|
if s.runs != nil {
|
||||||
@@ -545,6 +576,83 @@ func (s *Server) handleStreamExecution(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRunsStream is a global SSE feed of run_created events. UI tabs
|
||||||
|
// subscribe once and react to runs from any source (manual trigger, agent
|
||||||
|
// trigger, GitHub push). Heartbeats every 15s.
|
||||||
|
func (s *Server) handleRunsStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusInternalServerError, errors.New("streaming not supported"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
ch, cancel := s.runsBus.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, _ = fmt.Fprint(w, "event: open\ndata: {}\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
heartbeat := time.NewTicker(15 * time.Second)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case <-heartbeat.C:
|
||||||
|
_, _ = fmt.Fprint(w, ": heartbeat\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
case ev, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := ev.marshal()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, "data: %s\n\n", payload)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNodeLog streams the archived log for one node of an execution.
|
||||||
|
// Reads through the configured logstore (MinIO/S3 in prod) so the browser
|
||||||
|
// never talks to the object store directly. text/plain.
|
||||||
|
func (s *Server) handleNodeLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.logs == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, errors.New("log archive not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.PathValue("id")
|
||||||
|
node := r.PathValue("node")
|
||||||
|
if id == "" || node == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, errors.New("execution id and node required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rc, err := s.logs.Get(r.Context(), id, node)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, logstore.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, errors.New("log not found (run may still be in progress)"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusBadGateway, fmt.Errorf("log fetch: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
if _, err := io.Copy(w, rc); err != nil {
|
||||||
|
// Connection may have dropped; nothing to do.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleListExecutions returns recent runs from storage. Optional
|
// handleListExecutions returns recent runs from storage. Optional
|
||||||
// ?pipeline_id= filters by source pipeline; ?limit= caps the page size.
|
// ?pipeline_id= filters by source pipeline; ?limit= caps the page size.
|
||||||
func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListExecutions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Package logstore archives per-node execution logs to S3-compatible
|
||||||
|
// object storage (MinIO in dev). The API server writes one object per
|
||||||
|
// node when the node finishes; the live SSE feed continues to stream
|
||||||
|
// during execution.
|
||||||
|
//
|
||||||
|
// Object key layout:
|
||||||
|
// <bucket>/runs/<execID>/<nodeName>.log
|
||||||
|
package logstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the interface the API server uses. Implementations include the
|
||||||
|
// MinIO/S3-backed Mongo (mongo) store and a memory store for tests.
|
||||||
|
type Store interface {
|
||||||
|
Put(ctx context.Context, execID, node string, data []byte) error
|
||||||
|
Get(ctx context.Context, execID, node string) (io.ReadCloser, error)
|
||||||
|
Exists(ctx context.Context, execID, node string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotFound is returned by Get when the object doesn't exist.
|
||||||
|
var ErrNotFound = errors.New("log not found")
|
||||||
|
|
||||||
|
// Config carries connection details for a MinIO/S3 endpoint.
|
||||||
|
type Config struct {
|
||||||
|
Endpoint string // host:port, no scheme — e.g. "minio:9000" or "127.0.0.1:9000"
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
Bucket string
|
||||||
|
UseSSL bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minio is the production Store implementation, backed by minio-go.
|
||||||
|
type Minio struct {
|
||||||
|
cli *minio.Client
|
||||||
|
bucket string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMinio dials the endpoint with a 10s timeout and ensures the bucket
|
||||||
|
// exists. Returns an error if either step fails.
|
||||||
|
func NewMinio(ctx context.Context, cfg Config) (*Minio, error) {
|
||||||
|
if cfg.Endpoint == "" {
|
||||||
|
return nil, errors.New("MINIO_ENDPOINT is required")
|
||||||
|
}
|
||||||
|
if cfg.Bucket == "" {
|
||||||
|
cfg.Bucket = "flow-logs"
|
||||||
|
}
|
||||||
|
cli, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||||
|
Secure: cfg.UseSSL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("minio client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
exists, err := cli.BucketExists(bucketCtx, cfg.Bucket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("minio bucket check %q: %w", cfg.Bucket, err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
if err := cli.MakeBucket(bucketCtx, cfg.Bucket, minio.MakeBucketOptions{}); err != nil {
|
||||||
|
return nil, fmt.Errorf("minio make bucket %q: %w", cfg.Bucket, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Minio{cli: cli, bucket: cfg.Bucket}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Minio) Put(ctx context.Context, execID, node string, data []byte) error {
|
||||||
|
if execID == "" || node == "" {
|
||||||
|
return errors.New("execID and node required")
|
||||||
|
}
|
||||||
|
key := keyFor(execID, node)
|
||||||
|
_, err := m.cli.PutObject(ctx, m.bucket, key,
|
||||||
|
bytes.NewReader(data), int64(len(data)),
|
||||||
|
minio.PutObjectOptions{ContentType: "text/plain; charset=utf-8"})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Minio) Get(ctx context.Context, execID, node string) (io.ReadCloser, error) {
|
||||||
|
key := keyFor(execID, node)
|
||||||
|
obj, err := m.cli.GetObject(ctx, m.bucket, key, minio.GetObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// minio-go returns a lazy *Object; calling Stat surfaces NoSuchKey.
|
||||||
|
if _, err := obj.Stat(); err != nil {
|
||||||
|
_ = obj.Close()
|
||||||
|
var er minio.ErrorResponse
|
||||||
|
if errors.As(err, &er) && er.Code == "NoSuchKey" {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return obj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Minio) Exists(ctx context.Context, execID, node string) (bool, error) {
|
||||||
|
key := keyFor(execID, node)
|
||||||
|
_, err := m.cli.StatObject(ctx, m.bucket, key, minio.StatObjectOptions{})
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var er minio.ErrorResponse
|
||||||
|
if errors.As(err, &er) && er.Code == "NoSuchKey" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyFor returns the object key for a given execution+node pair. We sanitise
|
||||||
|
// the node name so weird characters (slashes, spaces) don't bork S3 keys.
|
||||||
|
func keyFor(execID, node string) string {
|
||||||
|
return "runs/" + execID + "/" + sanitize(node) + ".log"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitize(s string) string {
|
||||||
|
// Replace anything that isn't alnum/-/_ with "-".
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z',
|
||||||
|
r >= 'A' && r <= 'Z',
|
||||||
|
r >= '0' && r <= '9',
|
||||||
|
r == '-', r == '_', r == '.':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteRune('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := b.String()
|
||||||
|
if out == "" {
|
||||||
|
out = "node"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -88,6 +88,12 @@ function AgentDetail() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
// Reload recent runs whenever any run is dispatched (manual / agent /
|
||||||
|
// GitHub push). Cheap — `load()` is one round-trip.
|
||||||
|
const es = new EventSource(api.runsStreamURL());
|
||||||
|
es.onmessage = () => load();
|
||||||
|
es.onerror = () => {};
|
||||||
|
return () => es.close();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { EmptySection } from "@/components/empty-section";
|
||||||
|
|
||||||
|
export default function ApprovalsPage() {
|
||||||
|
return (
|
||||||
|
<EmptySection
|
||||||
|
title="Approvals"
|
||||||
|
description="An inbox for pending Approval-node decisions across runs. The wiring exists (Restate awakeables + the Resume panel on each run page); this page will surface them in one place."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { EmptySection } from "@/components/empty-section";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<EmptySection
|
||||||
|
title="Dashboard"
|
||||||
|
description="A summary of agents, pipelines, recent runs and approvals will land here. For now, the Pipelines and Agents pages have everything you need."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { EmptySection } from "@/components/empty-section";
|
||||||
|
|
||||||
|
export default function EnvironmentsPage() {
|
||||||
|
return (
|
||||||
|
<EmptySection
|
||||||
|
title="Environments"
|
||||||
|
description="Per-environment config (dev / staging / prod, runtime targets, secrets bindings) for agent deployments. Hooks into the Promote node."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+507
-291
@@ -3,7 +3,7 @@
|
|||||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { ArrowLeft, RefreshCw, Send } from "lucide-react";
|
import { ChevronDown, ChevronRight, RefreshCw, Send } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -18,25 +18,26 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { PipelineCanvas } from "@/components/canvas/pipeline-canvas";
|
import { PipelineCanvas } from "@/components/canvas/pipeline-canvas";
|
||||||
import { api, type ExecutionStatus, type Run } from "@/lib/api";
|
import { api, type ExecutionStatus, type Run } from "@/lib/api";
|
||||||
import type { PipelineDefinition } from "@/lib/pipeline-graph";
|
import type { PipelineDefinition, PipelineNode } from "@/lib/pipeline-graph";
|
||||||
|
import { lookup as lookupNode } from "@/lib/node-catalog";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
type NodeStatus = "pending" | "running" | "success" | "failed" | "paused";
|
type NodeStatus = "pending" | "running" | "success" | "failed" | "paused";
|
||||||
type NodeStatuses = Record<string, NodeStatus>;
|
type NodeStatuses = Record<string, NodeStatus>;
|
||||||
|
type NodeLogs = Record<string, string[]>;
|
||||||
|
type NodeDurations = Record<string, number>;
|
||||||
|
|
||||||
interface ExecutionEvent {
|
interface ExecutionEvent {
|
||||||
type: string;
|
type: string;
|
||||||
node?: string;
|
node?: string;
|
||||||
node_type?: string;
|
node_type?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
content?: string; // for node_log events
|
content?: string;
|
||||||
outputs?: Record<string, unknown>;
|
outputs?: Record<string, unknown>;
|
||||||
error?: string;
|
error?: string;
|
||||||
duration_ms?: number;
|
duration_ms?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeLogLine = { ts: number; line: string };
|
|
||||||
type NodeLogs = Record<string, NodeLogLine[]>;
|
|
||||||
|
|
||||||
export default function ExecutionPage() {
|
export default function ExecutionPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
@@ -74,21 +75,17 @@ function ExecutionView() {
|
|||||||
const [run, setRun] = useState<Run | null>(null);
|
const [run, setRun] = useState<Run | null>(null);
|
||||||
const [pipelineDef, setPipelineDef] = useState<PipelineDefinition | null>(null);
|
const [pipelineDef, setPipelineDef] = useState<PipelineDefinition | null>(null);
|
||||||
const [nodeStatuses, setNodeStatuses] = useState<NodeStatuses>({});
|
const [nodeStatuses, setNodeStatuses] = useState<NodeStatuses>({});
|
||||||
const [events, setEvents] = useState<ExecutionEvent[]>([]);
|
|
||||||
const [nodeLogs, setNodeLogs] = useState<NodeLogs>({});
|
const [nodeLogs, setNodeLogs] = useState<NodeLogs>({});
|
||||||
const [activeLogNode, setActiveLogNode] = useState<string | null>(null);
|
const [nodeDurations, setNodeDurations] = useState<NodeDurations>({});
|
||||||
const [streamConnected, setStreamConnected] = useState(false);
|
const [streamConnected, setStreamConnected] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tab, setTab] = useState<"canvas" | "json">("canvas");
|
|
||||||
|
|
||||||
// Resume form
|
// Resume form
|
||||||
const [awakeable, setAwakeable] = useState("");
|
const [awakeable, setAwakeable] = useState("");
|
||||||
const [data, setData] = useState(`{"approved": true}`);
|
const [data, setData] = useState(`{"approved": true}`);
|
||||||
const [resuming, setResuming] = useState(false);
|
const [resuming, setResuming] = useState(false);
|
||||||
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
// Initial load
|
||||||
|
|
||||||
// Initial load: status + run record + pipeline def.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -105,7 +102,7 @@ function ExecutionView() {
|
|||||||
const f = await api.getFlow(r.pipelineId);
|
const f = await api.getFlow(r.pipelineId);
|
||||||
setPipelineDef((f.definition as PipelineDefinition | null) ?? null);
|
setPipelineDef((f.definition as PipelineDefinition | null) ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
// Pipeline may have been deleted; render JSON view only.
|
/* pipeline may have been deleted */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -114,41 +111,24 @@ function ExecutionView() {
|
|||||||
})();
|
})();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
// SSE subscription. On open, mark all known nodes as pending. Each
|
// SSE
|
||||||
// node_started/completed/error event flips that node's status.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const url = api.executionStreamURL(id);
|
const es = new EventSource(api.executionStreamURL(id));
|
||||||
const es = new EventSource(url);
|
|
||||||
eventSourceRef.current = es;
|
|
||||||
es.onopen = () => setStreamConnected(true);
|
es.onopen = () => setStreamConnected(true);
|
||||||
es.onerror = () => {
|
es.onerror = () => setStreamConnected(false);
|
||||||
setStreamConnected(false);
|
|
||||||
// EventSource auto-reconnects on transient errors; only close on
|
|
||||||
// permanent ones. We log but don't bail.
|
|
||||||
};
|
|
||||||
es.onmessage = (msg) => {
|
es.onmessage = (msg) => {
|
||||||
try {
|
try {
|
||||||
const ev: ExecutionEvent = JSON.parse(msg.data);
|
const ev: ExecutionEvent = JSON.parse(msg.data);
|
||||||
|
|
||||||
// Log lines are high-frequency — keep them out of the generic
|
|
||||||
// events array (used for the JSON debug view) and route into
|
|
||||||
// their own state instead.
|
|
||||||
if (ev.type === "node_log" && ev.node && typeof ev.content === "string") {
|
if (ev.type === "node_log" && ev.node && typeof ev.content === "string") {
|
||||||
const node = ev.node;
|
const node = ev.node;
|
||||||
setNodeLogs((prev) => {
|
setNodeLogs((prev) => {
|
||||||
const cur = prev[node] ?? [];
|
const cur = prev[node] ?? [];
|
||||||
const next = cur.length >= 2000 ? cur.slice(-1999) : cur;
|
const next = cur.length >= 2000 ? cur.slice(-1999) : cur;
|
||||||
return {
|
return { ...prev, [node]: [...next, ev.content!] };
|
||||||
...prev,
|
|
||||||
[node]: [...next, { ts: Date.now(), line: ev.content! }],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
setActiveLogNode((prev) => prev ?? node);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEvents((prev) => [...prev.slice(-99), ev]);
|
|
||||||
if (ev.node) {
|
if (ev.node) {
|
||||||
setNodeStatuses((prev) => {
|
setNodeStatuses((prev) => {
|
||||||
const next: NodeStatus =
|
const next: NodeStatus =
|
||||||
@@ -161,8 +141,13 @@ function ExecutionView() {
|
|||||||
: (prev[ev.node!] ?? "pending");
|
: (prev[ev.node!] ?? "pending");
|
||||||
return { ...prev, [ev.node!]: next };
|
return { ...prev, [ev.node!]: next };
|
||||||
});
|
});
|
||||||
if (ev.type === "node_started") {
|
if (ev.type === "node_completed" || ev.type === "node_error") {
|
||||||
setActiveLogNode(ev.node);
|
if (typeof ev.duration_ms === "number") {
|
||||||
|
setNodeDurations((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[ev.node!]: ev.duration_ms!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ev.type === "done") {
|
if (ev.type === "done") {
|
||||||
@@ -171,26 +156,44 @@ function ExecutionView() {
|
|||||||
setStreamConnected(false);
|
setStreamConnected(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed messages.
|
/* ignore */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
es.close();
|
es.close();
|
||||||
eventSourceRef.current = null;
|
|
||||||
setStreamConnected(false);
|
setStreamConnected(false);
|
||||||
};
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
// Fallback polling for terminal status — useful if SSE failed to connect.
|
// Fallback polling when SSE drops
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id || streamConnected) return;
|
if (!id || streamConnected) return;
|
||||||
const t = setInterval(() => {
|
const t = setInterval(() => {
|
||||||
api.getExecution(id).then(setStatus).catch(() => {});
|
api.getExecution(id).then(setStatus).catch(() => {});
|
||||||
}, 3000);
|
}, 4000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [id, streamConnected]);
|
}, [id, streamConnected]);
|
||||||
|
|
||||||
|
// Hydrate canvas node statuses from the terminal payload whenever we
|
||||||
|
// have status + pipeline def. This handles the "joined after the run
|
||||||
|
// finished" case — without it, the canvas stays at PENDING forever
|
||||||
|
// because SSE has nothing left to replay. SSE-derived statuses are not
|
||||||
|
// overwritten so an in-flight run is still authoritative.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!status || !pipelineDef) return;
|
||||||
|
const overall = String(status.status || "").toLowerCase();
|
||||||
|
const isTerm = ["success", "completed", "failed", "error", "partial_error"].includes(overall);
|
||||||
|
if (!isTerm) return;
|
||||||
|
setNodeStatuses((prev) => {
|
||||||
|
const next: NodeStatuses = { ...prev };
|
||||||
|
for (const n of pipelineDef.nodes ?? []) {
|
||||||
|
if (next[n.name]) continue; // SSE wins
|
||||||
|
next[n.name] = inferStatus(status, n.name);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [status, pipelineDef]);
|
||||||
|
|
||||||
async function onResume() {
|
async function onResume() {
|
||||||
setResuming(true);
|
setResuming(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -207,9 +210,14 @@ function ExecutionView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const overallStatus = useMemo(() => {
|
const overallStatus = useMemo(
|
||||||
return (status?.status as string) || run?.status || "running";
|
() => (status?.status as string) || run?.status || "running",
|
||||||
}, [status, run]);
|
[status, run]
|
||||||
|
);
|
||||||
|
const isTerminal = ["success", "completed", "failed", "error", "partial_error"]
|
||||||
|
.includes(overallStatus.toLowerCase());
|
||||||
|
|
||||||
|
const nodes: PipelineNode[] = pipelineDef?.nodes ?? [];
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return (
|
return (
|
||||||
@@ -220,55 +228,147 @@ function ExecutionView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="space-y-6 p-6 pb-24">
|
||||||
{/* Toolbar */}
|
{/* Header */}
|
||||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b bg-background/80 px-3 backdrop-blur">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<Button variant="ghost" size="icon" asChild aria-label="Back">
|
<div>
|
||||||
<Link href="/">
|
<h1 className="text-3xl font-semibold tracking-tight">Run</h1>
|
||||||
<ArrowLeft className="size-4" />
|
<div className="mt-1 font-mono text-xs text-muted-foreground">{id}</div>
|
||||||
</Link>
|
{run?.pipelineId && (
|
||||||
</Button>
|
<Link
|
||||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
href={`/flows/view/?id=${encodeURIComponent(run.pipelineId)}`}
|
||||||
Execution
|
className="mt-1 inline-block text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
pipeline {run.pipelineId.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-xs">{id}</span>
|
<Badge variant={statusVariant(overallStatus)} className="gap-1">
|
||||||
<Badge variant={statusVariant(overallStatus)}>{overallStatus}</Badge>
|
<span
|
||||||
{streamConnected ? (
|
|
||||||
<Badge variant="success" className="text-[10px]">
|
|
||||||
● live
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
polling
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<div className="flex-1" />
|
|
||||||
<div className="inline-flex rounded-md border bg-muted/30 p-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTab("canvas")}
|
|
||||||
className={
|
className={
|
||||||
"rounded-sm px-3 py-1 text-xs " +
|
"size-1.5 rounded-full " +
|
||||||
(tab === "canvas"
|
(overallStatus === "running"
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-sky-500 animate-pulse"
|
||||||
: "text-muted-foreground hover:text-foreground")
|
: overallStatus === "success" || overallStatus === "completed"
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: overallStatus === "failed" || overallStatus === "error"
|
||||||
|
? "bg-rose-500"
|
||||||
|
: "bg-muted-foreground")
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
Canvas
|
{overallStatus}
|
||||||
</button>
|
</Badge>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={() => setTab("json")}
|
{error && (
|
||||||
className={
|
<Card className="border-destructive/40">
|
||||||
"rounded-sm px-3 py-1 text-xs " +
|
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
|
||||||
(tab === "json"
|
</Card>
|
||||||
? "bg-background text-foreground shadow-sm"
|
)}
|
||||||
: "text-muted-foreground hover:text-foreground")
|
|
||||||
}
|
{/* Pipeline canvas card */}
|
||||||
>
|
<Card>
|
||||||
JSON
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
</button>
|
<CardTitle>Pipeline</CardTitle>
|
||||||
</div>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{nodes.length} {nodes.length === 1 ? "node" : "nodes"}
|
||||||
|
</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="relative h-[440px] overflow-hidden rounded-lg border bg-background">
|
||||||
|
{pipelineDef ? (
|
||||||
|
<PipelineCanvas
|
||||||
|
initialValue={pipelineDef}
|
||||||
|
pipelineId={`exec-${id}`}
|
||||||
|
readOnly
|
||||||
|
fullBleed
|
||||||
|
nodeStatuses={nodeStatuses}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{run?.pipelineId
|
||||||
|
? "Loading pipeline canvas…"
|
||||||
|
: "No pipeline definition for this run."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Nodes panel */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Nodes</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Per-node detail. Build logs stream live and archive to S3 when the
|
||||||
|
node finishes.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{nodes.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No nodes loaded.</p>
|
||||||
|
) : (
|
||||||
|
nodes.map((n) => (
|
||||||
|
<NodeRow
|
||||||
|
key={n.id}
|
||||||
|
node={n}
|
||||||
|
status={nodeStatuses[n.name] ?? (isTerminal ? inferStatus(status, n.name) : "pending")}
|
||||||
|
durationMs={nodeDurations[n.name]}
|
||||||
|
liveLog={nodeLogs[n.name]}
|
||||||
|
streaming={streamConnected && nodeStatuses[n.name] === "running"}
|
||||||
|
isTerminal={isTerminal}
|
||||||
|
executionId={id}
|
||||||
|
statusOutputs={status?.node_outputs as Record<string, unknown> | undefined}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Resume overlay if paused */}
|
||||||
|
{(overallStatus === "paused" || overallStatus === "waiting") && (
|
||||||
|
<Card className="fixed bottom-6 right-6 z-30 w-80 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Resume</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Resolve a Restate awakeable to continue.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="awakeable">Awakeable ID</Label>
|
||||||
|
<Input
|
||||||
|
id="awakeable"
|
||||||
|
value={awakeable}
|
||||||
|
onChange={(e) => setAwakeable(e.target.value)}
|
||||||
|
placeholder="awk_…"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="data">Resolution data (JSON)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="data"
|
||||||
|
rows={4}
|
||||||
|
value={data}
|
||||||
|
onChange={(e) => setData(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={onResume}
|
||||||
|
disabled={resuming || !awakeable}
|
||||||
|
>
|
||||||
|
<Send />
|
||||||
|
{resuming ? "Sending…" : "Resume"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -278,222 +378,338 @@ function ExecutionView() {
|
|||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{error && (
|
// inferStatus reads the orchestrator's terminal payload to decide whether a
|
||||||
<div className="border-b border-destructive/40 bg-destructive/5 px-4 py-2 text-xs text-destructive">
|
// node ran. Used after `done` when the SSE map may be incomplete (e.g. user
|
||||||
{error}
|
// landed on the page after the run finished).
|
||||||
|
function inferStatus(
|
||||||
|
status: ExecutionStatus | null,
|
||||||
|
nodeName: string
|
||||||
|
): NodeStatus {
|
||||||
|
if (!status) return "pending";
|
||||||
|
const outs = status.node_outputs as Record<string, unknown> | undefined;
|
||||||
|
if (outs && nodeName in outs) return "success";
|
||||||
|
const errs = (status.errors as string[] | undefined) ?? [];
|
||||||
|
for (const e of errs) {
|
||||||
|
if (typeof e === "string" && e.includes(`node "${nodeName}"`)) return "failed";
|
||||||
|
}
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeRow(props: {
|
||||||
|
node: PipelineNode;
|
||||||
|
status: NodeStatus;
|
||||||
|
durationMs?: number;
|
||||||
|
liveLog?: string[];
|
||||||
|
streaming: boolean;
|
||||||
|
isTerminal: boolean;
|
||||||
|
executionId: string;
|
||||||
|
statusOutputs?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const { node, status, durationMs, liveLog, streaming, isTerminal, executionId, statusOutputs } = props;
|
||||||
|
const entry = lookupNode(node.type);
|
||||||
|
const typeLabel = entry?.label?.toLowerCase() ?? node.type.replace("flow-nodes-base.", "");
|
||||||
|
|
||||||
|
const buildSummary = extractBuildSummary(node.name, statusOutputs);
|
||||||
|
const triggerSummary = extractTriggerSummary(node.name, statusOutputs);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/10">
|
||||||
|
<div className="flex items-center justify-between gap-2 p-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-base font-medium">{node.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{typeLabel}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
{typeof durationMs === "number" && (
|
||||||
|
<span>took {(durationMs / 1000).toFixed(1)}s</span>
|
||||||
|
)}
|
||||||
|
<NodeStatusDot status={status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build node — image / digest / log path */}
|
||||||
|
{buildSummary && (
|
||||||
|
<div className="border-t px-4 py-3 font-mono text-[11px] leading-6">
|
||||||
|
{buildSummary.image && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">image: </span>
|
||||||
|
<span>{buildSummary.image}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{buildSummary.digest && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">digest: </span>
|
||||||
|
<span className="break-all">{buildSummary.digest}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{buildSummary.commit && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">sha: </span>
|
||||||
|
<span>{buildSummary.commit}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{buildSummary.command && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">command: </span>
|
||||||
|
<span>{buildSummary.command}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Always render the log path so the field appears even when archived. */}
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">log: </span>
|
||||||
|
<span>s3://flow-logs/runs/{executionId}/{node.name}.log</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative min-h-0 flex-1">
|
{triggerSummary && (
|
||||||
{tab === "canvas" ? (
|
<div className="border-t px-4 py-3 font-mono text-[11px] leading-6">
|
||||||
<div className="flex h-full flex-col">
|
<div>
|
||||||
<div className="relative min-h-0 flex-1">
|
<span className="text-muted-foreground">source: </span>
|
||||||
{pipelineDef ? (
|
<span>{triggerSummary.source ?? "manual"}</span>
|
||||||
<PipelineCanvas
|
</div>
|
||||||
initialValue={pipelineDef}
|
{triggerSummary.repoUrl && (
|
||||||
pipelineId={`exec-${id}`}
|
<div>
|
||||||
readOnly
|
<span className="text-muted-foreground">repo: </span>
|
||||||
fullBleed
|
<span>{triggerSummary.repoUrl}</span>
|
||||||
nodeStatuses={nodeStatuses}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
{run?.pipelineId
|
|
||||||
? "Loading pipeline canvas…"
|
|
||||||
: "No pipeline definition available for this run."}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<LogPanel
|
)}
|
||||||
nodeLogs={nodeLogs}
|
{triggerSummary.ref && (
|
||||||
activeNode={activeLogNode}
|
<div>
|
||||||
onActiveNodeChange={setActiveLogNode}
|
<span className="text-muted-foreground">ref: </span>
|
||||||
nodeStatuses={nodeStatuses}
|
<span>{triggerSummary.ref}</span>
|
||||||
streaming={streamConnected}
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div className="grid h-full grid-cols-1 gap-4 overflow-auto p-4 lg:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Status</CardTitle>
|
|
||||||
<CardDescription>Latest snapshot from the orchestrator</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-3 text-xs">
|
|
||||||
{status ? JSON.stringify(status, null, 2) : "Loading…"}
|
|
||||||
</pre>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Event log</CardTitle>
|
|
||||||
<CardDescription>Streamed via SSE</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-3 text-[11px]">
|
|
||||||
{events.length === 0
|
|
||||||
? "(no events yet)"
|
|
||||||
: events
|
|
||||||
.map((e) => JSON.stringify(e))
|
|
||||||
.join("\n")}
|
|
||||||
</pre>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Resume panel — overlaid only when paused */}
|
{/* Build log disclosure — visible for all node types that emitted lines */}
|
||||||
{(overallStatus === "paused" || overallStatus === "waiting") && (
|
{(liveLog?.length || isTerminal) && (
|
||||||
<Card className="absolute right-4 top-4 z-20 w-80 shadow-xl">
|
<LogDisclosure
|
||||||
<CardHeader>
|
executionId={executionId}
|
||||||
<CardTitle>Resume</CardTitle>
|
node={node}
|
||||||
<CardDescription>
|
liveLog={liveLog}
|
||||||
Resolve the Restate awakeable to continue.
|
streaming={streaming}
|
||||||
</CardDescription>
|
isTerminal={isTerminal}
|
||||||
</CardHeader>
|
status={status}
|
||||||
<CardContent className="space-y-3">
|
fallbackLog={buildSummary?.logTail}
|
||||||
<div className="space-y-1.5">
|
/>
|
||||||
<Label htmlFor="awakeable">Awakeable ID</Label>
|
)}
|
||||||
<Input
|
|
||||||
id="awakeable"
|
|
||||||
value={awakeable}
|
|
||||||
onChange={(e) => setAwakeable(e.target.value)}
|
|
||||||
placeholder="awk_…"
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="data">Resolution data (JSON)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="data"
|
|
||||||
rows={4}
|
|
||||||
value={data}
|
|
||||||
onChange={(e) => setData(e.target.value)}
|
|
||||||
spellCheck={false}
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={onResume}
|
|
||||||
disabled={resuming || !awakeable}
|
|
||||||
>
|
|
||||||
<Send />
|
|
||||||
{resuming ? "Sending…" : "Resume"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogPanel renders the per-node live log output below the canvas. Tabs at
|
function NodeStatusDot({ status }: { status: NodeStatus }) {
|
||||||
// the top let the user pick which node's stream they're watching; the
|
if (status === "running") {
|
||||||
// active tab follows the most-recently-started node by default.
|
return (
|
||||||
function LogPanel(props: {
|
<span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-500">
|
||||||
nodeLogs: NodeLogs;
|
<span className="size-1.5 rounded-full bg-sky-500 animate-pulse" />
|
||||||
activeNode: string | null;
|
running
|
||||||
onActiveNodeChange: (node: string | null) => void;
|
</span>
|
||||||
nodeStatuses: NodeStatuses;
|
);
|
||||||
streaming: boolean;
|
|
||||||
}) {
|
|
||||||
const { nodeLogs, activeNode, onActiveNodeChange, nodeStatuses, streaming } = props;
|
|
||||||
const nodes = Object.keys(nodeLogs);
|
|
||||||
const lines = activeNode ? (nodeLogs[activeNode] ?? []) : [];
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom on new lines unless the user has scrolled up.
|
|
||||||
const stickRef = useRef(true);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
if (stickRef.current) {
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [lines.length, activeNode]);
|
|
||||||
|
|
||||||
function onScroll() {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
|
||||||
stickRef.current = atBottom;
|
|
||||||
}
|
}
|
||||||
|
if (status === "success") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-500">
|
||||||
|
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||||
|
succeeded
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "failed") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-rose-600 dark:text-rose-500">
|
||||||
|
<span className="size-1.5 rounded-full bg-rose-500" />
|
||||||
|
failed
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "paused") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-500">
|
||||||
|
<span className="size-1.5 rounded-full bg-amber-500" />
|
||||||
|
paused
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
|
<span className="size-1.5 rounded-full bg-muted-foreground/60" />
|
||||||
|
pending
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogDisclosure(props: {
|
||||||
|
executionId: string;
|
||||||
|
node: PipelineNode;
|
||||||
|
liveLog?: string[];
|
||||||
|
streaming: boolean;
|
||||||
|
isTerminal: boolean;
|
||||||
|
status: NodeStatus;
|
||||||
|
/** Fallback log content from the orchestrator's terminal payload
|
||||||
|
* (e.g. `__build.log_tail`). Used when the archive isn't configured
|
||||||
|
* and we joined the page after the run finished. */
|
||||||
|
fallbackLog?: string;
|
||||||
|
}) {
|
||||||
|
const { executionId, node, liveLog, streaming, isTerminal, status, fallbackLog } = props;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [archived, setArchived] = useState<string | null>(null);
|
||||||
|
const [archiveErr, setArchiveErr] = useState<string | null>(null);
|
||||||
|
const fetched = useRef(false);
|
||||||
|
|
||||||
|
const live = liveLog ?? [];
|
||||||
|
// Show archived log when: run is terminal, no live lines this session,
|
||||||
|
// and the archive endpoint returns 200.
|
||||||
|
const tryArchive = isTerminal && live.length === 0 && (status === "success" || status === "failed");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !tryArchive || fetched.current) return;
|
||||||
|
fetched.current = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/executions/${executionId}/logs/${encodeURIComponent(node.name)}`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
setArchiveErr(`archive ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setArchived(await res.text());
|
||||||
|
} catch (e) {
|
||||||
|
setArchiveErr(e instanceof Error ? e.message : "fetch failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [open, tryArchive, executionId, node.name]);
|
||||||
|
|
||||||
|
// Pick the best content source available, in priority order:
|
||||||
|
// 1. live SSE buffer (this session)
|
||||||
|
// 2. archived object (MinIO, if configured)
|
||||||
|
// 3. fallbackLog (last 80 lines from log_tail in node_outputs)
|
||||||
|
const usingArchive = archived !== null;
|
||||||
|
const usingFallback = !usingArchive && live.length === 0 && Boolean(fallbackLog);
|
||||||
|
|
||||||
|
const labelStr = streaming
|
||||||
|
? "live"
|
||||||
|
: live.length > 0
|
||||||
|
? "live (cached)"
|
||||||
|
: usingArchive
|
||||||
|
? "archived"
|
||||||
|
: usingFallback
|
||||||
|
? "tail"
|
||||||
|
: tryArchive && archiveErr
|
||||||
|
? `unarchived (${archiveErr})`
|
||||||
|
: "no output yet";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 shrink-0 flex-col border-t bg-background">
|
<div className="border-t">
|
||||||
<div className="flex items-center gap-1 overflow-x-auto border-b px-2 py-1">
|
<button
|
||||||
<span className="px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
type="button"
|
||||||
Logs
|
onClick={() => setOpen((v) => !v)}
|
||||||
</span>
|
className="flex w-full items-center justify-between px-4 py-2 text-left text-xs font-medium hover:bg-muted/30"
|
||||||
{nodes.length === 0 ? (
|
|
||||||
<span className="px-2 text-xs text-muted-foreground">
|
|
||||||
{streaming ? "waiting for output…" : "no logs yet"}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
nodes.map((n) => {
|
|
||||||
const status = nodeStatuses[n];
|
|
||||||
const isActive = activeNode === n;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={n}
|
|
||||||
onClick={() => onActiveNodeChange(n)}
|
|
||||||
className={
|
|
||||||
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors " +
|
|
||||||
(isActive
|
|
||||||
? "bg-muted text-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-muted/60")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
"size-1.5 rounded-full " +
|
|
||||||
(status === "running"
|
|
||||||
? "bg-sky-500 animate-pulse"
|
|
||||||
: status === "success"
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: status === "failed"
|
|
||||||
? "bg-rose-500"
|
|
||||||
: "bg-muted-foreground/60")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="truncate max-w-[160px]">{n}</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{nodeLogs[n].length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
<div className="flex-1" />
|
|
||||||
{streaming && (
|
|
||||||
<span className="px-2 text-[10px] text-emerald-600 dark:text-emerald-500">
|
|
||||||
● live
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={onScroll}
|
|
||||||
className="flex-1 overflow-auto bg-zinc-950 px-3 py-2 font-mono text-[11px] leading-[1.4] text-zinc-200"
|
|
||||||
>
|
>
|
||||||
{lines.length === 0 ? (
|
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
<div className="text-zinc-500">
|
{open ? (
|
||||||
{activeNode
|
<ChevronDown className="size-3.5" />
|
||||||
? "waiting for output…"
|
) : (
|
||||||
: "select a node tab above"}
|
<ChevronRight className="size-3.5" />
|
||||||
</div>
|
)}
|
||||||
) : (
|
build log
|
||||||
lines.map((l, i) => (
|
</span>
|
||||||
<div key={i} className="whitespace-pre-wrap break-all">
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
{l.line}
|
{labelStr}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="border-t bg-zinc-950 px-3 py-2 font-mono text-[11px] leading-[1.45] text-zinc-200">
|
||||||
|
{live.length > 0 ? (
|
||||||
|
<div className="max-h-80 overflow-auto">
|
||||||
|
{live.map((l, i) => (
|
||||||
|
<div key={i} className="whitespace-pre-wrap break-all">
|
||||||
|
{l}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
) : usingArchive ? (
|
||||||
)}
|
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-all text-zinc-200">
|
||||||
</div>
|
{archived}
|
||||||
|
</pre>
|
||||||
|
) : tryArchive && archived === null && !archiveErr ? (
|
||||||
|
<div className="text-zinc-500">loading archive…</div>
|
||||||
|
) : usingFallback ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 text-[10px] uppercase tracking-wider text-amber-400">
|
||||||
|
Showing the last lines from the orchestrator (log archive
|
||||||
|
{archiveErr ? ` ${archiveErr}` : " not configured"}). Set
|
||||||
|
MINIO_ENDPOINT on the flow service to enable full archive.
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-all text-zinc-200">
|
||||||
|
{fallbackLog}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-zinc-500">waiting for output…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull image / digest / commit / command from a Build node's output items
|
||||||
|
// in the orchestrator's terminal node_outputs payload.
|
||||||
|
function extractBuildSummary(
|
||||||
|
nodeName: string,
|
||||||
|
outputs?: Record<string, unknown>
|
||||||
|
): { image?: string; digest?: string; commit?: string; command?: string; logTail?: string } | null {
|
||||||
|
if (!outputs) return null;
|
||||||
|
const slot = outputs[nodeName];
|
||||||
|
if (!slot || typeof slot !== "object") return null;
|
||||||
|
// node_outputs[name] = {0: [items]}
|
||||||
|
const portMap = slot as Record<string, unknown>;
|
||||||
|
const port = portMap["0"];
|
||||||
|
if (!Array.isArray(port) || port.length === 0) return null;
|
||||||
|
const item = port[0] as Record<string, unknown>;
|
||||||
|
const build = item["__build"] as Record<string, unknown> | undefined;
|
||||||
|
if (!build) return null;
|
||||||
|
const out: {
|
||||||
|
image?: string;
|
||||||
|
digest?: string;
|
||||||
|
commit?: string;
|
||||||
|
command?: string;
|
||||||
|
logTail?: string;
|
||||||
|
} = {};
|
||||||
|
if (typeof build.image === "string") out.image = build.image;
|
||||||
|
if (typeof build.commit === "string") out.commit = build.commit;
|
||||||
|
if (typeof build.command === "string") out.command = build.command;
|
||||||
|
if (typeof build.log_tail === "string") {
|
||||||
|
out.logTail = build.log_tail;
|
||||||
|
const m = build.log_tail.match(/sha256:[0-9a-f]{64}/);
|
||||||
|
if (m) out.digest = m[0];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTriggerSummary(
|
||||||
|
nodeName: string,
|
||||||
|
outputs?: Record<string, unknown>
|
||||||
|
): { source?: string; repoUrl?: string; ref?: string } | null {
|
||||||
|
if (!outputs) return null;
|
||||||
|
const slot = outputs[nodeName];
|
||||||
|
if (!slot || typeof slot !== "object") return null;
|
||||||
|
const portMap = slot as Record<string, unknown>;
|
||||||
|
const port = portMap["0"];
|
||||||
|
if (!Array.isArray(port) || port.length === 0) return null;
|
||||||
|
const item = port[0] as Record<string, unknown>;
|
||||||
|
// Heuristic: trigger items carry source/agentId/repoUrl/ref.
|
||||||
|
if (!item.source && !item.repoUrl) return null;
|
||||||
|
return {
|
||||||
|
source: typeof item.source === "string" ? item.source : undefined,
|
||||||
|
repoUrl: typeof item.repoUrl === "string" ? item.repoUrl : undefined,
|
||||||
|
ref: typeof item.ref === "string" ? item.ref : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { EmptySection } from "@/components/empty-section";
|
||||||
|
|
||||||
|
export default function GatewayPage() {
|
||||||
|
return (
|
||||||
|
<EmptySection
|
||||||
|
title="Gateway"
|
||||||
|
description="Public ingress for deployed agents — routes, auth, rate limits, OpenTelemetry export. Replaces the per-runtime gateway (Bedrock AgentCore endpoints, Vertex Reasoning Engine routes, K8s Ingress) with a uniform layer."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-2
@@ -3,10 +3,11 @@ import "./globals.css";
|
|||||||
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
|
import { RunsListener } from "@/components/runs-listener";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "flow",
|
title: "Langship",
|
||||||
description: "Durable n8n-compatible workflow engine",
|
description: "Durable agent-pipeline runtime",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -25,6 +26,7 @@ export default function RootLayout({
|
|||||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
<RunsListener />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, Play, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { api, type Run } from "@/lib/api";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
|
function statusVariant(s?: string) {
|
||||||
|
switch ((s || "").toLowerCase()) {
|
||||||
|
case "success":
|
||||||
|
case "completed":
|
||||||
|
return "success" as const;
|
||||||
|
case "running":
|
||||||
|
case "pending":
|
||||||
|
return "secondary" as const;
|
||||||
|
case "failed":
|
||||||
|
case "error":
|
||||||
|
return "destructive" as const;
|
||||||
|
case "waiting":
|
||||||
|
case "paused":
|
||||||
|
return "warning" as const;
|
||||||
|
default:
|
||||||
|
return "outline" as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RunsListPage() {
|
||||||
|
const [runs, setRuns] = useState<Run[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const list = await api.listRuns({ limit: 50 });
|
||||||
|
setRuns(list);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "load failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 2000);
|
||||||
|
// Push notifications: reload immediately when any run is created.
|
||||||
|
const es = new EventSource(api.runsStreamURL());
|
||||||
|
es.onmessage = () => load();
|
||||||
|
es.onerror = () => {
|
||||||
|
/* polling fallback covers it */
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
clearInterval(t);
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Runs</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Recent pipeline executions across all agents.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={load}>
|
||||||
|
<RefreshCw />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runs === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : runs.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||||
|
<Play className="size-5 text-muted-foreground" />
|
||||||
|
<div className="text-sm font-medium">No runs yet</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Trigger a run from an agent or pipeline.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{runs.map((r) => (
|
||||||
|
<Card key={r.id} className="transition-colors hover:bg-muted/30">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{r.pipelineName || r.pipelineId}
|
||||||
|
</span>
|
||||||
|
<Badge variant={statusVariant(r.status)}>{r.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="mt-0.5 truncate font-mono text-[10px]">
|
||||||
|
{r.id}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{formatDate(r.startedAt)}</span>
|
||||||
|
<Button size="sm" variant="ghost" asChild>
|
||||||
|
<Link href={`/executions/view/?id=${encodeURIComponent(r.id)}`}>
|
||||||
|
Open
|
||||||
|
<ArrowRight className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imports below are referenced via JSX above; ensure CardTitle isn't unused.
|
||||||
|
void CardTitle;
|
||||||
+145
-86
@@ -4,14 +4,18 @@ import * as React from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Workflow,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
|
||||||
PlusCircle,
|
|
||||||
Activity,
|
|
||||||
BookOpen,
|
|
||||||
ExternalLink,
|
|
||||||
Github,
|
|
||||||
Bot,
|
Bot,
|
||||||
|
GitBranch,
|
||||||
|
Play,
|
||||||
|
CheckSquare,
|
||||||
|
Layers,
|
||||||
|
Radio,
|
||||||
|
PanelLeft,
|
||||||
|
PanelLeftClose,
|
||||||
|
Sun,
|
||||||
|
MoonStar,
|
||||||
|
Monitor,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -19,18 +23,12 @@ import {
|
|||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarMenuSubItem,
|
|
||||||
SidebarSeparator,
|
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { PanelLeftClose, PanelLeft } from "lucide-react";
|
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -40,6 +38,12 @@ type NavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const primary: NavItem[] = [
|
const primary: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: "Dashboard",
|
||||||
|
href: "/dashboard",
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
match: (p) => p === "/dashboard",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Agents",
|
title: "Agents",
|
||||||
href: "/agents",
|
href: "/agents",
|
||||||
@@ -49,38 +53,32 @@ const primary: NavItem[] = [
|
|||||||
{
|
{
|
||||||
title: "Pipelines",
|
title: "Pipelines",
|
||||||
href: "/",
|
href: "/",
|
||||||
icon: LayoutGrid,
|
icon: GitBranch,
|
||||||
match: (p) => p === "/" || p.startsWith("/flows/view"),
|
match: (p) => p === "/" || p.startsWith("/flows"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "New pipeline",
|
title: "Runs",
|
||||||
href: "/flows/new",
|
href: "/runs",
|
||||||
icon: PlusCircle,
|
icon: Play,
|
||||||
match: (p) => p.startsWith("/flows/new"),
|
match: (p) => p === "/runs" || p.startsWith("/executions"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Executions",
|
title: "Approvals",
|
||||||
href: "/executions/view",
|
href: "/approvals",
|
||||||
icon: Activity,
|
icon: CheckSquare,
|
||||||
match: (p) => p.startsWith("/executions"),
|
match: (p) => p.startsWith("/approvals"),
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const docs = [
|
|
||||||
{
|
|
||||||
title: "n8n compatibility",
|
|
||||||
href: "https://github.com/lyzrai/flow#n8n-compatible",
|
|
||||||
external: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Durability model",
|
title: "Environments",
|
||||||
href: "https://github.com/lyzrai/flow#durability",
|
href: "/environments",
|
||||||
external: true,
|
icon: Layers,
|
||||||
|
match: (p) => p.startsWith("/environments"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Embed as Go library",
|
title: "Gateway",
|
||||||
href: "https://github.com/lyzrai/flow#embedding",
|
href: "/gateway",
|
||||||
external: true,
|
icon: Radio,
|
||||||
|
match: (p) => p.startsWith("/gateway"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -88,20 +86,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
const pathname = usePathname() || "/";
|
const pathname = usePathname() || "/";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar variant="floating" {...props}>
|
<Sidebar variant="floating" collapsible="icon" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg" asChild>
|
<SidebarMenuButton size="lg" asChild>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
<Workflow className="size-4" />
|
<LangshipMark />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span className="font-semibold">flow</span>
|
<span className="font-semibold">Langship</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
pre-v0.1 · durable pipelines
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -111,7 +106,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
|
|
||||||
<SidebarMenu className="gap-1">
|
<SidebarMenu className="gap-1">
|
||||||
{primary.map((item) => {
|
{primary.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
@@ -131,55 +125,15 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
})}
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
<SidebarSeparator />
|
|
||||||
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>
|
|
||||||
<BookOpen className="mr-1 size-3.5" />
|
|
||||||
Reference
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton className="font-medium" disabled>
|
|
||||||
Documentation
|
|
||||||
</SidebarMenuButton>
|
|
||||||
<SidebarMenuSub className="ml-0 border-l-0 px-1.5">
|
|
||||||
{docs.map((d) => (
|
|
||||||
<SidebarMenuSubItem key={d.href}>
|
|
||||||
<SidebarMenuSubButton asChild>
|
|
||||||
<a href={d.href} target="_blank" rel="noreferrer">
|
|
||||||
<span className="truncate">{d.title}</span>
|
|
||||||
{d.external && (
|
|
||||||
<ExternalLink className="ml-auto size-3 opacity-60" />
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenuSub>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild>
|
<CollapseToggle />
|
||||||
<a
|
|
||||||
href="https://github.com/lyzrai/flow"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<Github className="size-4" />
|
|
||||||
<span>GitHub</span>
|
|
||||||
<ExternalLink className="ml-auto size-3 opacity-60" />
|
|
||||||
</a>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<CollapseToggle />
|
<ThemeToggle />
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
@@ -187,6 +141,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LangshipMark() {
|
||||||
|
// Tiny anchor/route mark — placeholder for the real Langship logo.
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="size-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="6" cy="6" r="2" />
|
||||||
|
<circle cx="18" cy="18" r="2" />
|
||||||
|
<path d="M6 8v6a4 4 0 0 0 4 4h6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CollapseToggle() {
|
function CollapseToggle() {
|
||||||
const { toggleSidebar, state } = useSidebar();
|
const { toggleSidebar, state } = useSidebar();
|
||||||
const collapsed = state === "collapsed";
|
const collapsed = state === "collapsed";
|
||||||
@@ -198,3 +171,89 @@ function CollapseToggle() {
|
|||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThemeToggle is a 3-way segmented control (Light / System / Dark) that
|
||||||
|
// writes the theme to <html class>. Persists in localStorage. When the
|
||||||
|
// sidebar is collapsed to icons, it renders a single button that cycles
|
||||||
|
// through the three themes instead.
|
||||||
|
function ThemeToggle() {
|
||||||
|
const { state } = useSidebar();
|
||||||
|
const collapsed = state === "collapsed";
|
||||||
|
const [theme, setTheme] = React.useState<"light" | "dark" | "system">("system");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const saved =
|
||||||
|
(typeof window !== "undefined" &&
|
||||||
|
(localStorage.getItem("flow-theme") as
|
||||||
|
| "light"
|
||||||
|
| "dark"
|
||||||
|
| "system"
|
||||||
|
| null)) ||
|
||||||
|
"system";
|
||||||
|
setTheme(saved);
|
||||||
|
apply(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function apply(t: "light" | "dark" | "system") {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const prefersDark =
|
||||||
|
window.matchMedia &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
const dark = t === "dark" || (t === "system" && prefersDark);
|
||||||
|
root.classList.toggle("dark", dark);
|
||||||
|
localStorage.setItem("flow-theme", t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(next: "light" | "dark" | "system") {
|
||||||
|
setTheme(next);
|
||||||
|
apply(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: { id: "light" | "system" | "dark"; icon: typeof Sun; label: string }[] = [
|
||||||
|
{ id: "light", icon: Sun, label: "Light" },
|
||||||
|
{ id: "system", icon: Monitor, label: "System" },
|
||||||
|
{ id: "dark", icon: MoonStar, label: "Dark" },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
const current = items.find((i) => i.id === theme) ?? items[1];
|
||||||
|
const Icon = current.icon;
|
||||||
|
const next = items[(items.findIndex((i) => i.id === theme) + 1) % items.length].id;
|
||||||
|
return (
|
||||||
|
<SidebarMenuButton
|
||||||
|
onClick={() => pick(next)}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
title={`Theme: ${current.label} (click to cycle)`}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
<span>{current.label}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-2 mb-1 flex rounded-md border bg-muted/40 p-0.5">
|
||||||
|
{items.map((it) => {
|
||||||
|
const Icon = it.icon;
|
||||||
|
const active = theme === it.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={it.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pick(it.id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={
|
||||||
|
"flex flex-1 items-center justify-center gap-1 rounded-sm px-2 py-1 text-[11px] transition-colors " +
|
||||||
|
(active
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
<span>{it.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,33 +28,34 @@ export function FlowNode({ data, selected }: NodeProps) {
|
|||||||
const outputs = entry?.outputs ?? 1;
|
const outputs = entry?.outputs ?? 1;
|
||||||
const isTrigger = pn.type === "flow-nodes-base.trigger";
|
const isTrigger = pn.type === "flow-nodes-base.trigger";
|
||||||
|
|
||||||
|
// Per-type accent colour for the TYPE label. Keeps the existing
|
||||||
|
// catalog `color` (full bg) but extracts the hue for header text.
|
||||||
|
const typeAccent = typeAccentClass(entry?.color);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[200px] rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow",
|
"min-w-[260px] rounded-xl border bg-card px-4 py-3 text-card-foreground shadow-sm transition-shadow",
|
||||||
selected ? "ring-2 ring-primary shadow-md" : "hover:shadow-md",
|
selected ? "ring-2 ring-primary shadow-md" : "hover:shadow-md",
|
||||||
!entry && "border-destructive/60",
|
!entry && "border-destructive/60",
|
||||||
status === "running" && "ring-2 ring-sky-500 animate-pulse",
|
status === "running" && "ring-2 ring-sky-500/60",
|
||||||
status === "success" && "ring-2 ring-emerald-500",
|
status === "success" && "ring-1 ring-emerald-500/40",
|
||||||
status === "failed" && "ring-2 ring-rose-500",
|
status === "failed" && "ring-2 ring-rose-500",
|
||||||
status === "paused" && "ring-2 ring-amber-500"
|
status === "paused" && "ring-2 ring-amber-500"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-center justify-between gap-3">
|
||||||
className={cn(
|
<div className={cn("flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-widest", typeAccent)}>
|
||||||
"flex items-center gap-2 rounded-t-lg px-3 py-2 text-xs font-medium text-white",
|
<Icon className="size-3.5" />
|
||||||
entry?.color ?? "bg-destructive"
|
<span>{(entry?.label ?? "Unsupported")}</span>
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-3.5" />
|
|
||||||
<span className="truncate">{entry?.label ?? "Unsupported"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="truncate text-sm font-medium">{pn.name}</span>
|
|
||||||
<StatusIcon status={status} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-[11px] text-muted-foreground">{pn.type}</div>
|
<StatusPill status={status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<span className="truncate text-xl font-semibold leading-tight">
|
||||||
|
{pn.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input handle: triggers have no inputs */}
|
{/* Input handle: triggers have no inputs */}
|
||||||
@@ -85,15 +86,75 @@ export function FlowNode({ data, selected }: NodeProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusIcon({ status }: { status?: NodeRunStatus }) {
|
function StatusPill({ status }: { status?: NodeRunStatus }) {
|
||||||
if (!status || status === "pending") return null;
|
if (!status || status === "pending") {
|
||||||
if (status === "running")
|
return (
|
||||||
return <Loader2 className="size-3.5 animate-spin text-sky-500" />;
|
<span className="rounded-full border bg-muted/40 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
if (status === "success")
|
pending
|
||||||
return <CheckCircle2 className="size-3.5 text-emerald-500" />;
|
</span>
|
||||||
if (status === "failed")
|
);
|
||||||
return <XCircle className="size-3.5 text-rose-500" />;
|
}
|
||||||
if (status === "paused")
|
if (status === "running") {
|
||||||
return <PauseCircle className="size-3.5 text-amber-500" />;
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-sky-500/40 bg-sky-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-sky-700 dark:text-sky-400">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
running
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "success") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-500/40 bg-emerald-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-emerald-700 dark:text-emerald-400">
|
||||||
|
<CheckCircle2 className="size-3" />
|
||||||
|
succeeded
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "failed") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-rose-500/40 bg-rose-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-rose-700 dark:text-rose-400">
|
||||||
|
<XCircle className="size-3" />
|
||||||
|
failed
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "paused") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-amber-700 dark:text-amber-400">
|
||||||
|
<PauseCircle className="size-3" />
|
||||||
|
paused
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// typeAccentClass picks a tailwind text-color class that pairs with the
|
||||||
|
// node's catalog `color` (bg-X-500) so the TYPE row reads as a distinct
|
||||||
|
// accent against the card background.
|
||||||
|
function typeAccentClass(bg?: string): string {
|
||||||
|
switch (bg) {
|
||||||
|
case "bg-emerald-500":
|
||||||
|
return "text-emerald-500";
|
||||||
|
case "bg-amber-500":
|
||||||
|
return "text-amber-500";
|
||||||
|
case "bg-sky-500":
|
||||||
|
return "text-sky-500";
|
||||||
|
case "bg-violet-500":
|
||||||
|
return "text-violet-500";
|
||||||
|
case "bg-indigo-500":
|
||||||
|
return "text-indigo-500";
|
||||||
|
case "bg-fuchsia-500":
|
||||||
|
return "text-fuchsia-500";
|
||||||
|
case "bg-rose-500":
|
||||||
|
case "bg-red-600":
|
||||||
|
return "text-rose-500";
|
||||||
|
case "bg-orange-500":
|
||||||
|
return "text-orange-500";
|
||||||
|
case "bg-slate-500":
|
||||||
|
case "bg-slate-400":
|
||||||
|
return "text-slate-500";
|
||||||
|
default:
|
||||||
|
return "text-primary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function EmptySection(props: {
|
||||||
|
title: string;
|
||||||
|
description: ReactNode;
|
||||||
|
status?: "soon" | "alpha";
|
||||||
|
}) {
|
||||||
|
const { title, description, status = "soon" } = props;
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-start gap-6 p-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
<span className="rounded-md border bg-muted/30 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{status === "soon" ? "coming soon" : "alpha"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-lg text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Play, X } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
interface RunCreated {
|
||||||
|
type: string;
|
||||||
|
executionId: string;
|
||||||
|
pipelineId?: string;
|
||||||
|
pipelineName?: string;
|
||||||
|
agentId?: string;
|
||||||
|
source?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = RunCreated & { receivedAt: number };
|
||||||
|
|
||||||
|
// RunsListener mounts once at the app root, opens an SSE stream to
|
||||||
|
// /api/runs/stream, and renders a stack of "new run" toasts in the
|
||||||
|
// bottom-right corner. Each toast links to the live run view.
|
||||||
|
//
|
||||||
|
// Toasts auto-dismiss after 12s. Clicking the stream's link navigates to
|
||||||
|
// the live execution page (which has its own per-execution SSE feed).
|
||||||
|
export function RunsListener() {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let es: EventSource | null = null;
|
||||||
|
let backoff = 1000; // ms — grows on consecutive errors, capped at 30s
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
es = new EventSource(api.runsStreamURL());
|
||||||
|
es.onopen = () => {
|
||||||
|
backoff = 1000;
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
es?.close();
|
||||||
|
es = null;
|
||||||
|
// EventSource doesn't reconnect cleanly when nginx / cloudflared
|
||||||
|
// drops the connection — fall back to manual reconnect with
|
||||||
|
// bounded backoff so we keep getting events after a server restart.
|
||||||
|
const wait = Math.min(backoff, 30_000);
|
||||||
|
backoff = Math.min(backoff * 2, 30_000);
|
||||||
|
setTimeout(connect, wait);
|
||||||
|
};
|
||||||
|
es.onmessage = (msg) => {
|
||||||
|
try {
|
||||||
|
const ev: RunCreated = JSON.parse(msg.data);
|
||||||
|
if (ev.type !== "run_created" || !ev.executionId) return;
|
||||||
|
setToasts((prev) => {
|
||||||
|
const next = [...prev, { ...ev, receivedAt: Date.now() }];
|
||||||
|
// Cap visible stack to 4
|
||||||
|
return next.slice(-4);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
es?.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-dismiss
|
||||||
|
useEffect(() => {
|
||||||
|
if (toasts.length === 0) return;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
const cutoff = Date.now() - 12_000;
|
||||||
|
setToasts((prev) => prev.filter((x) => x.receivedAt > cutoff));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [toasts.length]);
|
||||||
|
|
||||||
|
function dismiss(id: string) {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.executionId !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex flex-col gap-2">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<ToastCard key={t.executionId} toast={t} onDismiss={dismiss} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastCard({
|
||||||
|
toast,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
toast: Toast;
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const sourceLabel =
|
||||||
|
toast.source === "github_push"
|
||||||
|
? "GitHub push"
|
||||||
|
: toast.source === "manual"
|
||||||
|
? "Manual trigger"
|
||||||
|
: (toast.source ?? "New run");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto flex w-80 items-start gap-3 rounded-lg border bg-background p-3 shadow-xl">
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<Play className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium">{sourceLabel}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{toast.pipelineName || toast.pipelineId || "pipeline"}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/executions/view/?id=${encodeURIComponent(toast.executionId)}`}
|
||||||
|
className="mt-1 inline-block text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Open live view →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDismiss(toast.executionId)}
|
||||||
|
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -171,6 +171,9 @@ export const api = {
|
|||||||
/** Returns the EventSource URL for SSE streaming of an execution. */
|
/** Returns the EventSource URL for SSE streaming of an execution. */
|
||||||
executionStreamURL: (id: string) => `${base}/api/executions/${id}/stream`,
|
executionStreamURL: (id: string) => `${base}/api/executions/${id}/stream`,
|
||||||
|
|
||||||
|
/** Global runs feed — fires once per dispatched run. */
|
||||||
|
runsStreamURL: () => `${base}/api/runs/stream`,
|
||||||
|
|
||||||
listRuns: (params?: { pipelineId?: string; limit?: number }) => {
|
listRuns: (params?: { pipelineId?: string; limit?: number }) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params?.pipelineId) qs.set("pipeline_id", params.pipelineId);
|
if (params?.pipelineId) qs.set("pipeline_id", params.pipelineId);
|
||||||
|
|||||||
Reference in New Issue
Block a user