chore: publish clean community baseline
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
target
|
||||
notes
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.idea
|
||||
.vscode
|
||||
.claude
|
||||
.codex
|
||||
.tmp
|
||||
.DS_Store
|
||||
*.log
|
||||
var
|
||||
apps/ui/node_modules
|
||||
apps/ui/playwright-report
|
||||
apps/ui/test-results
|
||||
apps/ui/.playwright
|
||||
@@ -0,0 +1,36 @@
|
||||
POSTGRES_DB=crank
|
||||
POSTGRES_USER=crank
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_MAX_CONNECTIONS=20
|
||||
POSTGRES_MIN_CONNECTIONS=2
|
||||
POSTGRES_ACQUIRE_TIMEOUT_MS=5000
|
||||
POSTGRES_IDLE_TIMEOUT_MS=600000
|
||||
POSTGRES_MAX_LIFETIME_MS=1800000
|
||||
CRANK_ADMIN_API_IMAGE=crank/admin-api:dev
|
||||
CRANK_MCP_SERVER_IMAGE=crank/mcp-server:dev
|
||||
CRANK_UI_IMAGE=crank/ui:dev
|
||||
CRANK_STORAGE_ROOT=/var/lib/crank/storage
|
||||
CRANK_PUBLISH_BIND=127.0.0.1
|
||||
CRANK_ADMIN_BIND=0.0.0.0:3001
|
||||
CRANK_ADMIN_RATE_LIMIT_RPS=30
|
||||
CRANK_ADMIN_RATE_LIMIT_BURST=60
|
||||
CRANK_MCP_BIND=0.0.0.0:3002
|
||||
CRANK_MCP_REFRESH_MS=5000
|
||||
CRANK_MCP_RATE_LIMIT_RPS=60
|
||||
CRANK_MCP_RATE_LIMIT_BURST=120
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16
|
||||
CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16
|
||||
CRANK_LOG_LEVEL=info
|
||||
CRANK_MASTER_KEY=change-me-master-key
|
||||
CRANK_SESSION_SECRET=change-me-session-secret
|
||||
CRANK_PASSWORD_PEPPER=change-me-password-pepper
|
||||
CRANK_SESSION_TTL_HOURS=24
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
||||
CRANK_DEMO_SEED=false
|
||||
CRANK_BASE_URL=https://crank.example.com
|
||||
@@ -0,0 +1,144 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "feat/**"
|
||||
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUST_TEST_THREADS: "2"
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust Checks
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
crank-rust-postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: crank_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d crank_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://postgres:postgres@crank-rust-postgres:5432/crank_test
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
cargo clippy --version
|
||||
docker --version
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all --check
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features --jobs "$CARGO_BUILD_JOBS" -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --all-targets --jobs "$CARGO_BUILD_JOBS"
|
||||
|
||||
ui:
|
||||
name: UI Checks
|
||||
runs-on: ubuntu-latest
|
||||
needs: rust
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
node --version
|
||||
npm --version
|
||||
docker --version
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: apps/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Build UI bundle
|
||||
working-directory: apps/ui
|
||||
run: npm run build
|
||||
|
||||
frontend-e2e:
|
||||
name: Frontend E2E
|
||||
runs-on: ubuntu-latest
|
||||
needs: ui
|
||||
services:
|
||||
crank-e2e-postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: crank
|
||||
POSTGRES_USER: crank
|
||||
POSTGRES_PASSWORD: crank
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U crank -d crank"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
CRANK_E2E_USE_EXTERNAL_POSTGRES: "1"
|
||||
CRANK_E2E_POSTGRES_HOST: crank-e2e-postgres
|
||||
CRANK_E2E_POSTGRES_PORT: "5432"
|
||||
CRANK_E2E_POSTGRES_DB: crank
|
||||
CRANK_E2E_POSTGRES_USER: crank
|
||||
CRANK_E2E_POSTGRES_PASSWORD: crank
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
rustc --version
|
||||
cargo --version
|
||||
node --version
|
||||
npm --version
|
||||
docker --version
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: apps/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browser
|
||||
working-directory: apps/ui
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Prebuild e2e services
|
||||
run: cargo build -p admin-api -p mcp-server --jobs "$CARGO_BUILD_JOBS"
|
||||
|
||||
- name: Run Playwright e2e
|
||||
working-directory: apps/ui
|
||||
run: npx playwright test
|
||||
|
||||
- name: Show Playwright stack logs
|
||||
if: failure()
|
||||
run: |
|
||||
find .tmp/ui-e2e/logs -maxdepth 1 -type f -print -exec sed -n '1,220p' {} \; || true
|
||||
|
||||
deployment:
|
||||
name: Deployment Manifests
|
||||
runs-on: ubuntu-latest
|
||||
needs: ui
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Validate Community deployment manifest
|
||||
run: docker compose -f deploy/community/docker-compose.yml --env-file deploy/community/.env.example config -q
|
||||
@@ -0,0 +1,230 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.itexp.me
|
||||
IMAGE_TAG: ${{ gitea.sha }}
|
||||
ADMIN_API_IMAGE: git.itexp.me/bsodfather/crank-community-admin-api
|
||||
MCP_SERVER_IMAGE: git.itexp.me/bsodfather/crank-community-mcp-server
|
||||
UI_IMAGE: git.itexp.me/bsodfather/crank-community-ui
|
||||
OPENBAO_ENV_FILE: .openbao-env
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
docker --version
|
||||
command -v bao
|
||||
bao version
|
||||
|
||||
- name: Load deployment secrets from OpenBao
|
||||
env:
|
||||
BAO_ADDR: ${{ secrets.BAO_ADDR }}
|
||||
BAO_ROLE_ID: ${{ secrets.BAO_ROLE_ID }}
|
||||
BAO_SECRET_ID: ${{ secrets.BAO_SECRET_ID }}
|
||||
OPENBAO_APP: crank
|
||||
run: scripts/load-openbao-env.sh
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | \
|
||||
docker login '${{ env.REGISTRY }}' -u "$DEPLOY_REGISTRY_USER" --password-stdin
|
||||
|
||||
- name: Build and push images
|
||||
run: |
|
||||
docker build -f apps/admin-api/Dockerfile \
|
||||
-t '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.ADMIN_API_IMAGE }}:main' \
|
||||
.
|
||||
docker build -f apps/mcp-server/Dockerfile \
|
||||
-t '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.MCP_SERVER_IMAGE }}:main' \
|
||||
.
|
||||
docker build -f apps/ui/Dockerfile \
|
||||
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.UI_IMAGE }}:main' \
|
||||
.
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:main'
|
||||
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.MCP_SERVER_IMAGE }}:main'
|
||||
docker push '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.UI_IMAGE }}:main'
|
||||
|
||||
- name: Configure SSH key
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
- name: Configure known hosts
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
else
|
||||
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > ~/.ssh/known_hosts
|
||||
fi
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Sync deployment files to server
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"mkdir -p '$DEPLOY_PATH'"
|
||||
rsync -az -e "ssh -p $DEPLOY_PORT" deploy/community/docker-compose.yml \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
|
||||
|
||||
- name: Write environment file
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
tmp_env="$(mktemp)"
|
||||
append_if_set() {
|
||||
if [ -n "$2" ]; then
|
||||
printf '%s=%s\n' "$1" "$2" >> "$tmp_env"
|
||||
fi
|
||||
}
|
||||
: > "$tmp_env"
|
||||
append_if_set POSTGRES_DB "$POSTGRES_DB"
|
||||
append_if_set POSTGRES_USER "$POSTGRES_USER"
|
||||
append_if_set POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
|
||||
append_if_set POSTGRES_HOST "$POSTGRES_HOST"
|
||||
if [ -n "${POSTGRES_PORT:-}" ]; then
|
||||
append_if_set POSTGRES_PORT "$POSTGRES_PORT"
|
||||
elif [ -n "${PGBOUNCER_PORT:-}" ]; then
|
||||
append_if_set POSTGRES_PORT "$PGBOUNCER_PORT"
|
||||
fi
|
||||
append_if_set CRANK_STORAGE_ROOT "$CRANK_STORAGE_ROOT"
|
||||
append_if_set CRANK_PUBLISH_BIND "$CRANK_PUBLISH_BIND"
|
||||
append_if_set CRANK_ADMIN_BIND "$CRANK_ADMIN_BIND"
|
||||
append_if_set CRANK_MCP_BIND "$CRANK_MCP_BIND"
|
||||
append_if_set CRANK_MCP_REFRESH_MS "$CRANK_MCP_REFRESH_MS"
|
||||
append_if_set CRANK_LOG_LEVEL "$CRANK_LOG_LEVEL"
|
||||
append_if_set CRANK_MASTER_KEY "$CRANK_MASTER_KEY"
|
||||
append_if_set CRANK_BASE_URL "$CRANK_BASE_URL"
|
||||
append_if_set CRANK_CACHE_BACKEND "$CRANK_CACHE_BACKEND"
|
||||
append_if_set CRANK_CACHE_URL "$CRANK_CACHE_URL"
|
||||
append_if_set CRANK_CACHE_DEFAULT_TTL_MS "$CRANK_CACHE_DEFAULT_TTL_MS"
|
||||
append_if_set CRANK_SESSION_SECRET "$CRANK_SESSION_SECRET"
|
||||
append_if_set CRANK_PASSWORD_PEPPER "$CRANK_PASSWORD_PEPPER"
|
||||
append_if_set CRANK_SESSION_TTL_HOURS "$CRANK_SESSION_TTL_HOURS"
|
||||
append_if_set CRANK_BOOTSTRAP_ADMIN_EMAIL "$CRANK_BOOTSTRAP_ADMIN_EMAIL"
|
||||
append_if_set CRANK_BOOTSTRAP_ADMIN_PASSWORD "$CRANK_BOOTSTRAP_ADMIN_PASSWORD"
|
||||
append_if_set CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME "$CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME"
|
||||
append_if_set CRANK_DEMO_SEED "$CRANK_DEMO_SEED"
|
||||
{
|
||||
printf 'COMPOSE_PROJECT_NAME=community\n'
|
||||
printf 'CRANK_ADMIN_API_IMAGE=%s:%s\n' '${{ env.ADMIN_API_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||
printf 'CRANK_MCP_SERVER_IMAGE=%s:%s\n' '${{ env.MCP_SERVER_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||
printf 'CRANK_UI_IMAGE=%s:%s\n' '${{ env.UI_IMAGE }}' '${{ env.IMAGE_TAG }}'
|
||||
} >> "$tmp_env"
|
||||
cat "$tmp_env" | ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"mkdir -p '$DEPLOY_PATH' && cat > '$DEPLOY_PATH/.env'"
|
||||
rm -f "$tmp_env"
|
||||
|
||||
- name: Validate required environment variables
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||
set -e
|
||||
cd '$DEPLOY_PATH'
|
||||
required_vars='
|
||||
POSTGRES_HOST
|
||||
POSTGRES_PORT
|
||||
POSTGRES_DB
|
||||
POSTGRES_USER
|
||||
POSTGRES_PASSWORD
|
||||
CRANK_MASTER_KEY
|
||||
CRANK_SESSION_SECRET
|
||||
CRANK_PASSWORD_PEPPER
|
||||
CRANK_BOOTSTRAP_ADMIN_EMAIL
|
||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD
|
||||
CRANK_BASE_URL
|
||||
'
|
||||
for var in \$required_vars; do
|
||||
value=\$(grep -E \"^\${var}=\" .env | tail -n1 | cut -d= -f2- || true)
|
||||
if [ -z \"\$value\" ]; then
|
||||
echo \"missing required env: \$var\" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
"
|
||||
|
||||
- name: Deploy with Docker Compose
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||
set -e
|
||||
cd '$DEPLOY_PATH'
|
||||
compose_profiles=''
|
||||
cache_backend=\$(grep -E '^CRANK_CACHE_BACKEND=' .env | tail -n1 | cut -d= -f2- || true)
|
||||
if [ \"\$cache_backend\" = 'valkey' ] || [ \"\$cache_backend\" = 'redis' ]; then
|
||||
compose_profiles='--profile cache'
|
||||
fi
|
||||
echo '$DEPLOY_REGISTRY_TOKEN' | docker login '${{ env.REGISTRY }}' -u '$DEPLOY_REGISTRY_USER' --password-stdin
|
||||
docker compose \$compose_profiles config -q
|
||||
docker compose \$compose_profiles pull
|
||||
docker compose \$compose_profiles down --remove-orphans
|
||||
for container in \
|
||||
crank-ui-1 \
|
||||
crank-admin-api-1 \
|
||||
crank-mcp-server-1 \
|
||||
crank-postgres-1 \
|
||||
crank-valkey-1 \
|
||||
crank-community-ui-1 \
|
||||
crank-community-admin-api-1 \
|
||||
crank-community-mcp-server-1 \
|
||||
crank-community-postgres-1 \
|
||||
crank-community-valkey-1; do
|
||||
if docker ps -a --format '{{.Names}}' | grep -Fx \"\$container\" >/dev/null; then
|
||||
docker rm -f \"\$container\"
|
||||
fi
|
||||
done
|
||||
echo 'Docker containers before freeing required ports:'
|
||||
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Ports}}'
|
||||
for port in 3000 3001 3002; do
|
||||
container_ids=\$(docker ps -aq --filter \"publish=\$port\")
|
||||
if [ -n \"\$container_ids\" ]; then
|
||||
echo \"Removing containers publishing port \$port\"
|
||||
docker inspect --format '{{.Name}} {{json .NetworkSettings.Ports}}' \$container_ids || true
|
||||
docker rm -f \$container_ids
|
||||
fi
|
||||
done
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltnp '( sport = :3000 or sport = :3001 or sport = :3002 )' || true
|
||||
fi
|
||||
docker compose \$compose_profiles up -d --remove-orphans
|
||||
"
|
||||
|
||||
- name: Verify health endpoints
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||
set -e
|
||||
cd '$DEPLOY_PATH'
|
||||
for attempt in \$(seq 1 30); do
|
||||
if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \
|
||||
&& curl --fail --silent http://127.0.0.1:3001/health >/dev/null \
|
||||
&& curl --fail --silent http://127.0.0.1:3002/health >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo 'deployment health verification failed' >&2
|
||||
docker compose ps >&2
|
||||
exit 1
|
||||
"
|
||||
@@ -0,0 +1,100 @@
|
||||
name: Community Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.itexp.me
|
||||
IMAGE_TAG: ${{ gitea.ref_name }}
|
||||
ADMIN_API_IMAGE: git.itexp.me/bsodfather/crank-community-admin-api
|
||||
MCP_SERVER_IMAGE: git.itexp.me/bsodfather/crank-community-mcp-server
|
||||
UI_IMAGE: git.itexp.me/bsodfather/crank-community-ui
|
||||
OPENBAO_ENV_FILE: .openbao-env
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Verify runner toolchain
|
||||
run: |
|
||||
rustc --version
|
||||
cargo --version
|
||||
node --version
|
||||
npm --version
|
||||
docker --version
|
||||
command -v bao
|
||||
bao version
|
||||
|
||||
- name: Build release binaries
|
||||
run: cargo build --release -p admin-api -p mcp-server
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: apps/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Build UI dist
|
||||
working-directory: apps/ui
|
||||
run: npm run build
|
||||
|
||||
- name: Package release artifacts
|
||||
run: |
|
||||
mkdir -p dist/release
|
||||
cp target/release/admin-api dist/release/admin-api
|
||||
cp target/release/mcp-server dist/release/mcp-server
|
||||
tar -C dist/release -czf dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz admin-api
|
||||
tar -C dist/release -czf dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz mcp-server
|
||||
tar -C apps/ui/dist -czf dist/crank-community-ui-${IMAGE_TAG}.tar.gz .
|
||||
sha256sum \
|
||||
dist/crank-community-admin-api-${IMAGE_TAG}.tar.gz \
|
||||
dist/crank-community-mcp-server-${IMAGE_TAG}.tar.gz \
|
||||
dist/crank-community-ui-${IMAGE_TAG}.tar.gz \
|
||||
> dist/crank-community-${IMAGE_TAG}-checksums.txt
|
||||
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/workspace" anchore/syft:latest \
|
||||
dir:/workspace -o spdx-json \
|
||||
> dist/crank-community-${IMAGE_TAG}-sbom.spdx.json
|
||||
|
||||
- name: Load deployment secrets from OpenBao
|
||||
env:
|
||||
BAO_ADDR: ${{ secrets.BAO_ADDR }}
|
||||
BAO_ROLE_ID: ${{ secrets.BAO_ROLE_ID }}
|
||||
BAO_SECRET_ID: ${{ secrets.BAO_SECRET_ID }}
|
||||
OPENBAO_APP: crank
|
||||
run: scripts/load-openbao-env.sh
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
. "$OPENBAO_ENV_FILE"
|
||||
printf '%s' "$DEPLOY_REGISTRY_TOKEN" | \
|
||||
docker login '${{ env.REGISTRY }}' -u "$DEPLOY_REGISTRY_USER" --password-stdin
|
||||
|
||||
- name: Build and push release images
|
||||
run: |
|
||||
docker build -f apps/admin-api/Dockerfile \
|
||||
-t '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.ADMIN_API_IMAGE }}:latest' .
|
||||
docker build -f apps/mcp-server/Dockerfile \
|
||||
-t '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.MCP_SERVER_IMAGE }}:latest' .
|
||||
docker build -f apps/ui/Dockerfile \
|
||||
-t '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}' \
|
||||
-t '${{ env.UI_IMAGE }}:latest' .
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.ADMIN_API_IMAGE }}:latest'
|
||||
docker push '${{ env.MCP_SERVER_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.MCP_SERVER_IMAGE }}:latest'
|
||||
docker push '${{ env.UI_IMAGE }}:${{ env.IMAGE_TAG }}'
|
||||
docker push '${{ env.UI_IMAGE }}:latest'
|
||||
|
||||
- name: Show release bundle summary
|
||||
run: |
|
||||
ls -lh dist
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/target
|
||||
/.idea
|
||||
/.vscode
|
||||
/.DS_Store
|
||||
/node_modules
|
||||
/dist
|
||||
/coverage
|
||||
/.env
|
||||
/.openbao-env
|
||||
/var
|
||||
/notes
|
||||
apps/ui/node_modules
|
||||
apps/ui/dist
|
||||
apps/ui/playwright-report
|
||||
apps/ui/test-results
|
||||
.tmp
|
||||
*.tsbuildinfo
|
||||
apps/ui/vite.config.js
|
||||
apps/ui/vite.config.d.ts
|
||||
*.log
|
||||
__*.md
|
||||
diploma/
|
||||
AGENTS.md
|
||||
TASKS.md
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n operations.id,\n operations.workspace_id,\n operations.name,\n operations.display_name,\n operations.category,\n operations.protocol,\n operations.security_level,\n ov.target_json,\n operations.status,\n operations.current_draft_version,\n operations.latest_published_version,\n operations.created_at as \"created_at!: time::OffsetDateTime\",\n operations.updated_at as \"updated_at!: time::OffsetDateTime\",\n operations.published_at as \"published_at: time::OffsetDateTime\"\n from operations\n join operation_versions ov\n on ov.operation_id = operations.id\n and ov.version = operations.current_draft_version\n where operations.workspace_id = $1\n order by operations.name asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "category",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "security_level",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "target_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "current_draft_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "latest_published_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0b8e9bed137684683d8b6a9ed52c94b6fb3bbcb19c7a621c78c5a6d3698bdf4d"
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2\n order by ov.version asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "category",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "security_level",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "operation_created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "operation_updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "operation_published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "target_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "input_schema_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "output_schema_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "input_mapping_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "output_mapping_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "execution_config_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tool_description_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "samples_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "generated_draft_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "config_export_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0fb9fabe7417aa52f0977efa0ad5b3f1975517a60f63bb09028b3acb3506d1eb"
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n agent_id,\n version,\n status,\n instructions_json,\n tool_selection_policy_json,\n created_at as \"created_at!: time::OffsetDateTime\"\n from agent_versions\n where agent_id = $1 and version = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "instructions_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "tool_selection_policy_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1912ca7a4b00fcc5c1a7fe5434b811977fbdf181436573fa96415ccfcb5fecb2"
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n operation_id,\n version,\n sample_kind,\n storage_ref,\n content_type,\n file_name,\n created_at as \"created_at!: time::OffsetDateTime\"\n from operation_samples\n where operation_id = $1 and version = $2\n order by created_at asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "sample_kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "storage_ref",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "content_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "file_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1b8c77657275ab0ea28e68ae5147dbdad5d055887ef5fabd82552d4fe704f891"
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n slug,\n display_name,\n description,\n status,\n current_draft_version,\n latest_published_version,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\",\n published_at as \"published_at: time::OffsetDateTime\"\n from agents\n where workspace_id = $1 and id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "slug",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "current_draft_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "latest_published_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "20a3404581cb52fa7087ba4649fde4910e8d7c2b1b17055fdd1c54206e55a874"
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from operation_versions ov\n join operations o on o.id = ov.operation_id\n where o.workspace_id = $1 and ov.operation_id = $2 and ov.version = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "category",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "security_level",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "operation_created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "operation_updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "operation_published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "target_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "input_schema_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "output_schema_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "input_mapping_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "output_mapping_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "execution_config_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tool_description_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "samples_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "generated_draft_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "config_export_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "21011878e679a08b38c588bae6e24f770221be24a289ba6d83ed32b6b051ed2b"
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "update users\n set email = $2,\n display_name = $3\n where id = $1\n returning\n id,\n email,\n display_name,\n status,\n created_at as \"created_at!: OffsetDateTime\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select exists(\n select 1\n from memberships\n where user_id = $1\n and workspace_id = $2\n ) as \"allowed!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "allowed!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2b819bad2a2712dab1a182b89ec52d762d66cffdc449ee6f73c74c1d23a17314"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select 1 as \"present!\"\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n where w.slug = $1 and a.slug = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "present!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3422060fba1de6d85f89c46b41cc1cf462f49f0e2fe93e9230d15f97710b74ee"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n created_at as \"created_at!: OffsetDateTime\"\n from users\n where email = $1\n limit 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "password_hash!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n created_at as \"created_at!: OffsetDateTime\"\n from users\n where id = $1\n limit 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "password_hash!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "insert into users (\n id,\n email,\n display_name,\n password_hash,\n status,\n created_at\n ) values (\n $1, $2, $3, $4, 'active', now()\n )\n on conflict (email) do update\n set display_name = excluded.display_name,\n password_hash = excluded.password_hash,\n status = 'active'\n returning id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3df72b8a0ac4a76b15909fc78268a1c87d1cd184973bf1cecc0c725691432c08"
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n slug,\n display_name,\n status,\n settings_json,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\"\n from workspaces\n where id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slug",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "settings_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7"
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n operation_id,\n version,\n descriptor_kind,\n storage_ref,\n source_name,\n package_index_json,\n created_at as \"created_at!: time::OffsetDateTime\"\n from descriptors\n where operation_id = $1 and version = $2\n order by created_at asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "descriptor_kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "storage_ref",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "source_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "package_index_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6ae136d8196f7b259b7e5d054ecbd063dc5746ef669f21b0db1b5843e9db22d0"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select 1 as \"present!\"\n from agents a\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b\n on b.agent_id = a.id and b.agent_version = pa.version\n where a.workspace_id = $1\n and b.operation_id = $2\n limit 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "present!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6eed48468f97843bbcd6a2f90448b68c15efa72029740e28bb881f54f73a6b9b"
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n w.id,\n w.slug,\n w.display_name,\n w.status,\n w.settings_json,\n w.created_at as \"created_at!: time::OffsetDateTime\",\n w.updated_at as \"updated_at!: time::OffsetDateTime\",\n m.role\n from memberships m\n join workspaces w on w.id = m.workspace_id\n where m.user_id = $1\n order by w.slug asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slug",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "settings_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "role",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb"
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n where po.operation_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "category",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "security_level",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "operation_created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "operation_updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "operation_published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "target_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "input_schema_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "output_schema_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "input_mapping_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "output_mapping_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "execution_config_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tool_description_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "samples_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "generated_draft_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "config_export_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "94c7e7cb5068c56f6caed09c71323f5efe2beba283e317af1157e7b262cf65ce"
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n slug,\n display_name,\n description,\n status,\n current_draft_version,\n latest_published_version,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\",\n published_at as \"published_at: time::OffsetDateTime\"\n from agents\n where workspace_id = $1\n order by slug asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "slug",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "current_draft_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "latest_published_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9625d5160a075b55b5bb980ea8e82e9da706a34d17adf0bc94ddb4e7a3818c18"
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n protocol,\n mode,\n status,\n cursor_json,\n state_json,\n expires_at as \"expires_at!: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n closed_at as \"closed_at: OffsetDateTime\"\n from stream_sessions\n where id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "mode",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "cursor_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "expires_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "last_poll_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "closed_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a1d2c9b16b61d226701449fc22146db6f0f7d17e6867f929c44027990fb94b57"
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n protocol,\n mode,\n status,\n cursor_json,\n state_json,\n expires_at as \"expires_at!: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n closed_at as \"closed_at: OffsetDateTime\"\n from stream_sessions\n where workspace_id = $1\n and ($2::text is null or agent_id = $2)\n and ($3::text is null or operation_id = $3)\n and ($4::text is null or status = $4)\n and ($5::text is null or mode = $5)\n order by created_at desc\n limit $6",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "mode",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "cursor_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "expires_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "last_poll_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "closed_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b68377d8ffd5bdc9b8e417170f4940b65d2167dada6b6110985ce0bd95480b93"
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n name,\n kind,\n config_json,\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\"\n from auth_profiles\n where workspace_id = $1\n order by name asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "config_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b86a7406eeec83390458e35fb8611e19377582ec0ca245a162c2d2236077bb9b"
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n protocol_version,\n initialized,\n workspace_slug,\n agent_slug,\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n expires_at as \"expires_at: OffsetDateTime\"\n from mcp_transport_sessions\n where id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "protocol_version",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "initialized",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "workspace_slug",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "agent_slug",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "expires_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bdae00d03a55ba3b6d121578b081e970ff08427f9a3dd396b649996a9131260c"
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n m.workspace_id,\n m.user_id,\n m.role,\n m.created_at as \"created_at!: time::OffsetDateTime\",\n u.email,\n u.display_name,\n u.status,\n u.created_at as \"user_created_at!: time::OffsetDateTime\"\n from memberships m\n join users u on u.id = m.user_id\n where m.workspace_id = $1\n order by u.email asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "role",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "user_created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc"
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n w.id as workspace_id,\n w.slug as workspace_slug,\n a.id as agent_id,\n a.slug as agent_slug,\n b.tool_name,\n b.tool_title,\n coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",\n o.id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from workspaces w\n join agents a on a.workspace_id = w.id\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version\n join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version\n join operations o on o.id = ov.operation_id and o.workspace_id = w.id\n where w.slug = $1 and a.slug = $2 and b.enabled = true\n order by b.tool_name asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_slug",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "agent_slug",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "tool_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "tool_title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "tool_description!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "category",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "security_level",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "operation_created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "operation_updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "operation_published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "target_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "input_schema_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "output_schema_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "input_mapping_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "output_mapping_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "execution_config_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "tool_description_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "samples_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"name": "generated_draft_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "config_export_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"name": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c570f37d6cbc556f763f8a05d3b225b2ba5e5694c46c84deda01f99751eec3ec"
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n name,\n kind,\n status,\n current_version,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\",\n last_used_at as \"last_used_at: time::OffsetDateTime\"\n from secrets\n where workspace_id = $1 and id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "current_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "last_used_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89"
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n slug,\n display_name,\n status,\n settings_json,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\"\n from workspaces\n order by slug asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slug",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "settings_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f"
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n name,\n kind,\n status,\n current_version,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\",\n last_used_at as \"last_used_at: time::OffsetDateTime\"\n from secrets\n where workspace_id = $1\n order by name asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "current_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "last_used_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b"
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n status,\n progress_json,\n result_json,\n error_json,\n expires_at as \"expires_at: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n finished_at as \"finished_at: OffsetDateTime\"\n from async_jobs\n where workspace_id = $1\n and ($2::text is null or agent_id = $2)\n and ($3::text is null or operation_id = $3)\n and ($4::text is null or status = $4)\n order by updated_at desc\n limit $5",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "progress_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "result_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "error_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "expires_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "last_poll_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "finished_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e86682005480df007b488b8543adc8a6d4068e33a5509f9e314dd0d97eac178b"
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n email,\n role,\n status,\n token_hash,\n expires_at as \"expires_at!: time::OffsetDateTime\",\n created_at as \"created_at!: time::OffsetDateTime\"\n from invitation_tokens\n where workspace_id = $1\n order by created_at desc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "role",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "token_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expires_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41"
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n operations.id,\n operations.workspace_id,\n operations.name,\n operations.display_name,\n operations.category,\n operations.protocol,\n operations.security_level,\n ov.target_json,\n operations.status,\n operations.current_draft_version,\n operations.latest_published_version,\n operations.created_at as \"created_at!: time::OffsetDateTime\",\n operations.updated_at as \"updated_at!: time::OffsetDateTime\",\n operations.published_at as \"published_at: time::OffsetDateTime\"\n from operations\n join operation_versions ov\n on ov.operation_id = operations.id\n and ov.version = operations.current_draft_version\n where operations.workspace_id = $1 and operations.id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "category",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "security_level",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "target_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "current_draft_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "latest_published_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f195cc4951ef129f4a1c95d6d9e8f932919d2882f3dcb5a7f865fb4448506d05"
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n agent_id,\n operation_id,\n status,\n progress_json,\n result_json,\n error_json,\n expires_at as \"expires_at: OffsetDateTime\",\n last_poll_at as \"last_poll_at: OffsetDateTime\",\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\",\n finished_at as \"finished_at: OffsetDateTime\"\n from async_jobs\n where id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "progress_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "result_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "error_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "expires_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "last_poll_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "finished_at: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f551a9cd253b7fd0dc9a387a141a53d8a109b8aa766ab1c2b11f9b52e250adc8"
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n s.id,\n s.user_id,\n s.current_workspace_id,\n u.email,\n u.display_name,\n u.status,\n u.created_at as \"created_at!: OffsetDateTime\"\n from user_sessions s\n join users u on u.id = s.user_id\n where s.id = $1\n and s.secret_hash = $2\n and s.status = 'active'\n and s.expires_at > now()\n limit 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "current_workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d"
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n id,\n workspace_id,\n name,\n kind,\n config_json,\n created_at as \"created_at!: OffsetDateTime\",\n updated_at as \"updated_at!: OffsetDateTime\"\n from auth_profiles\n where workspace_id = $1 and id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "config_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at!: OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fadf243775a355c46ae97d26049793eb1d1ad17505b1e1609a842750e3233ec2"
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n sv.secret_id,\n sv.version,\n sv.ciphertext,\n sv.key_version,\n sv.created_at as \"created_at!: time::OffsetDateTime\",\n sv.created_by\n from secrets s\n join secret_versions sv\n on sv.secret_id = s.id and sv.version = s.current_version\n where s.workspace_id = $1 and s.id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "secret_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "ciphertext",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "key_version",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a"
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n b.operation_id,\n a.id as agent_id,\n a.slug as agent_slug,\n a.display_name\n from agents a\n join published_agents pa on pa.agent_id = a.id\n join agent_operation_bindings b\n on b.agent_id = a.id and b.agent_version = pa.version\n where a.workspace_id = $1\n order by a.display_name asc, b.tool_name asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "operation_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "agent_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_slug",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fdd05b145021a19dda9a9f44c31ff21a2f73a4416d0cf20be418bba36def9eb4"
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select\n o.id,\n o.workspace_id,\n o.name,\n o.display_name,\n o.category,\n o.protocol,\n o.security_level,\n o.created_at as \"operation_created_at!: time::OffsetDateTime\",\n o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",\n o.published_at as \"operation_published_at: time::OffsetDateTime\",\n ov.version,\n ov.status,\n ov.target_json,\n ov.input_schema_json,\n ov.output_schema_json,\n ov.input_mapping_json,\n ov.output_mapping_json,\n ov.execution_config_json,\n ov.tool_description_json,\n ov.samples_json,\n ov.generated_draft_json,\n ov.config_export_json,\n ov.wizard_state_json,\n ov.change_note,\n ov.created_at as \"created_at!: time::OffsetDateTime\",\n ov.created_by\n from published_operations po\n join operation_versions ov\n on ov.operation_id = po.operation_id and ov.version = po.version\n join operations o on o.id = po.operation_id\n order by o.name asc",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "display_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "category",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "protocol",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "security_level",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "operation_created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "operation_updated_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "operation_published_at: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "target_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "input_schema_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "output_schema_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "input_mapping_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "output_mapping_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "execution_config_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tool_description_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "samples_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "generated_draft_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "config_export_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "wizard_state_json",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "change_note",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "created_at!: time::OffsetDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "created_by",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ff7cac26067a7845a033db286befb7762b051412ac83c0238fe7ce426c02d4d4"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Contributor License Agreement
|
||||
|
||||
Этот документ описывает условия, на которых проект Crank принимает внешние вклады в код, документацию, тесты, примеры и другие материалы.
|
||||
|
||||
Перед отправкой pull request автор вклада должен согласиться с этими условиями. Если вклад сделан от имени компании, автор подтверждает, что имеет право передать вклад на этих условиях.
|
||||
|
||||
## 1. Что считается вкладом
|
||||
|
||||
Вкладом считается любой материал, намеренно отправленный в проект Crank:
|
||||
|
||||
- исходный код;
|
||||
- тесты;
|
||||
- документация;
|
||||
- примеры конфигурации;
|
||||
- исправления ошибок;
|
||||
- предложения, если они оформлены как конкретные изменения в репозитории.
|
||||
|
||||
## 2. Права на вклад
|
||||
|
||||
Автор вклада подтверждает, что:
|
||||
|
||||
- он является правообладателем вклада или имеет необходимые права для его передачи;
|
||||
- вклад не нарушает права третьих лиц;
|
||||
- вклад не содержит кода или материалов, которые нельзя использовать в проекте Crank на условиях этого соглашения.
|
||||
|
||||
## 3. Лицензия на вклад
|
||||
|
||||
Автор предоставляет владельцу проекта Crank бессрочную, всемирную, безотзывную, неисключительную, безвозмездную лицензию на использование вклада.
|
||||
|
||||
Эта лицензия включает право:
|
||||
|
||||
- использовать вклад;
|
||||
- копировать вклад;
|
||||
- изменять вклад;
|
||||
- объединять вклад с другими материалами;
|
||||
- распространять вклад;
|
||||
- публиковать вклад;
|
||||
- сублицензировать вклад;
|
||||
- включать вклад в открытые, коммерческие и закрытые версии Crank.
|
||||
|
||||
## 4. Патенты
|
||||
|
||||
Если вклад затрагивает патентуемые решения, автор предоставляет владельцу проекта и пользователям Crank безвозмездную лицензию на патентные притязания автора, необходимые для использования вклада в составе Crank.
|
||||
|
||||
## 5. Отсутствие гарантий
|
||||
|
||||
Вклад передается без гарантий. Автор не отвечает за убытки, возникшие из-за использования вклада, если иное прямо не установлено законом или отдельным письменным соглашением.
|
||||
|
||||
## 6. Лицензия проекта
|
||||
|
||||
Crank Community распространяется по лицензии GNU Affero General Public License v3.0 only.
|
||||
|
||||
Владелец проекта может использовать принятые вклады также в других редакциях Crank, включая коммерческие, облачные и закрытые редакции.
|
||||
|
||||
## 7. Как подтвердить согласие
|
||||
|
||||
Отправляя pull request, автор подтверждает согласие с этим CLA.
|
||||
|
||||
В комментарии к pull request можно указать:
|
||||
|
||||
```text
|
||||
I agree to the Crank Contributor License Agreement.
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
# Участие в разработке
|
||||
|
||||
Crank Community принимает исправления ошибок, улучшения документации, тесты и доработки открытой версии проекта.
|
||||
|
||||
## Лицензия вкладов
|
||||
|
||||
Crank Community распространяется по лицензии GNU Affero General Public License v3.0 only.
|
||||
|
||||
Перед отправкой pull request нужно согласиться с [Contributor License Agreement](./CLA.md). Это нужно, чтобы проект мог использовать принятые изменения не только в Community-версии, но и в коммерческих редакциях Crank.
|
||||
|
||||
## Перед pull request
|
||||
|
||||
Проверьте форматирование и тесты:
|
||||
|
||||
```bash
|
||||
just fmt-check
|
||||
just clippy
|
||||
just test
|
||||
```
|
||||
|
||||
Для изменений веб-интерфейса также выполните:
|
||||
|
||||
```bash
|
||||
cd apps/ui
|
||||
npm ci
|
||||
npm run build
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
## Требования к изменениям
|
||||
|
||||
- Не добавляйте функциональность вне границ Community-версии.
|
||||
- Не добавляйте секреты, токены, приватные адреса и локальные настройки.
|
||||
- Не коммитьте `AGENTS.md`, `TASKS.md`, `.env` и временные файлы.
|
||||
- Для изменений SQL-запросов обновляйте `.sqlx`, если это требуется SQLx.
|
||||
- Для пользовательских изменений обновляйте документацию или примеры.
|
||||
|
||||
## Границы Community-версии
|
||||
|
||||
В этом репозитории поддерживаются:
|
||||
|
||||
- REST API как источник MCP-инструментов;
|
||||
- простая авторизация администратора;
|
||||
- ключи агентов для MCP-доступа;
|
||||
- одно самостоятельное развертывание;
|
||||
- PostgreSQL как основное хранилище;
|
||||
- необязательный Valkey или Redis для служебного кэша.
|
||||
|
||||
Функции коммерческих редакций не должны попадать в этот репозиторий.
|
||||
Generated
+3330
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/admin-api",
|
||||
"apps/mcp-server",
|
||||
"crates/crank-community-auth",
|
||||
"crates/crank-community-mcp",
|
||||
"crates/crank-core",
|
||||
"crates/crank-schema",
|
||||
"crates/crank-mapping",
|
||||
"crates/crank-registry",
|
||||
"crates/crank-runtime",
|
||||
"crates/crank-adapter-rest",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
rust-version = "1.85"
|
||||
version = "0.3.1"
|
||||
|
||||
[workspace.dependencies]
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
axum = "0.8"
|
||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||
base64 = "0.22"
|
||||
hkdf = "0.12"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
sha2 = "0.10"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "macros", "json", "time"] }
|
||||
thiserror = "2"
|
||||
time = { version = "0.3", features = ["formatting", "parsing", "serde"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
uuid = { version = "1", features = ["serde", "v7"] }
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,7 @@
|
||||
Crank
|
||||
Copyright 2026 bsodfather
|
||||
|
||||
This product includes software developed for the Crank project.
|
||||
|
||||
Crank Community is licensed under the GNU Affero General Public License
|
||||
version 3 only. Commercial licensing for other editions is handled separately.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Crank
|
||||
|
||||

|
||||
|
||||
Crank - это свободная платформа для создания MCP-инструментов из REST API эндпоинтов.
|
||||
|
||||
Сервис ставится на собственный сервер, подключается к PostgreSQL и дает веб-интерфейс, в котором можно описать REST API, проверить вызов, опубликовать его как инструмент и подключить к MCP-клиенту.
|
||||
|
||||
## Что умеет Crank
|
||||
|
||||
- Создавать REST-инструменты через веб-интерфейс или YAML.
|
||||
- Принимать параметры от MCP-клиента и подставлять их в путь, query string, заголовки или тело REST-запроса.
|
||||
- Преобразовывать ответ REST API в структурированный результат для MCP-клиента.
|
||||
- Публиковать только выбранные инструменты для конкретного агента.
|
||||
- Хранить версии, черновики, примеры запросов и ответов, секреты, журналы вызовов и статистику в PostgreSQL.
|
||||
- Работать с простой авторизацией администратора: email, пароль и браузерная сессия.
|
||||
- Запускаться через Docker Compose.
|
||||
|
||||
## Быстрый запуск через Docker
|
||||
|
||||
Требования:
|
||||
|
||||
- Docker;
|
||||
- Docker Compose;
|
||||
- свободные порты `3000`, `3001`, `3002`;
|
||||
- доступ к опубликованным Docker-образам Crank.
|
||||
|
||||
Создайте рабочую папку:
|
||||
|
||||
```bash
|
||||
mkdir -p crank
|
||||
cd crank
|
||||
```
|
||||
|
||||
Скачайте compose-файл и пример настроек:
|
||||
|
||||
```bash
|
||||
curl -fsSLo docker-compose.yml https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/docker-compose.images.yml
|
||||
curl -fsSLo .env.example https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/.env.images.example
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Откройте скачанный `.env.example` и перенесите нужные значения в `.env`.
|
||||
|
||||
Минимально нужно заменить:
|
||||
|
||||
- `POSTGRES_PASSWORD`;
|
||||
- `CRANK_MASTER_KEY`;
|
||||
- `CRANK_SESSION_SECRET`;
|
||||
- `CRANK_PASSWORD_PEPPER`;
|
||||
- `CRANK_BOOTSTRAP_ADMIN_EMAIL`;
|
||||
- `CRANK_BOOTSTRAP_ADMIN_PASSWORD`;
|
||||
- `CRANK_BASE_URL`.
|
||||
|
||||
Секреты можно сгенерировать так:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Запустите Crank:
|
||||
|
||||
```bash
|
||||
docker compose --profile local-db up -d
|
||||
```
|
||||
|
||||
Если registry требует авторизацию, сначала выполните `docker login git.itexp.me`.
|
||||
|
||||
После запуска:
|
||||
|
||||
- веб-интерфейс: `http://localhost:3000`;
|
||||
- HTTP API панели управления: `http://localhost:3001`;
|
||||
- MCP-сервер: `http://localhost:3002`.
|
||||
|
||||
По умолчанию порты публикуются только на `127.0.0.1`. Это удобно, если перед Crank стоит nginx, Caddy или другой обратный прокси. Если нужно открыть порты наружу напрямую, укажите в `.env`:
|
||||
|
||||
```env
|
||||
CRANK_PUBLISH_BIND=0.0.0.0
|
||||
```
|
||||
|
||||
Проверить состояние контейнеров:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Посмотреть журналы:
|
||||
|
||||
```bash
|
||||
docker compose logs -f admin-api mcp-server ui
|
||||
```
|
||||
|
||||
Остановить сервис:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Обновить Crank до свежих образов:
|
||||
|
||||
```bash
|
||||
docker compose --profile local-db pull
|
||||
docker compose --profile local-db up -d
|
||||
```
|
||||
|
||||
## Запуск с внешним PostgreSQL
|
||||
|
||||
Если PostgreSQL уже запущен отдельно, профиль `local-db` не нужен.
|
||||
|
||||
В `.env` укажите параметры вашей базы:
|
||||
|
||||
- `POSTGRES_HOST`;
|
||||
- `POSTGRES_PORT`;
|
||||
- `POSTGRES_DB`;
|
||||
- `POSTGRES_USER`;
|
||||
- `POSTGRES_PASSWORD`.
|
||||
|
||||
Затем запустите только приложения Crank:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Если используется PgBouncer, укажите его адрес в `POSTGRES_HOST` и порт в `POSTGRES_PORT`.
|
||||
|
||||
## Запуск из исходников
|
||||
|
||||
Если нужно собрать образы самостоятельно, клонируйте репозиторий и используйте корневой [`docker-compose.yml`](./docker-compose.yml):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bsodfather/crank-community.git crank
|
||||
cd crank
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Этот вариант удобен для локальной проверки изменений перед отправкой патча.
|
||||
|
||||
## Как пользоваться
|
||||
|
||||
Обычный сценарий:
|
||||
|
||||
1. Зайти в веб-интерфейс.
|
||||
2. Создать REST-инструмент.
|
||||
3. Описать входные параметры и правила вызова REST API.
|
||||
4. Выполнить пробный запрос.
|
||||
5. Опубликовать инструмент.
|
||||
6. Привязать инструмент к агенту.
|
||||
7. Создать ключ агента.
|
||||
8. Подключить MCP-клиент к адресу агента.
|
||||
|
||||
Каждый агент имеет свой каталог инструментов. MCP-клиент видит только те REST-инструменты, которые явно привязаны к этому агенту.
|
||||
|
||||
## Что входит в эту версию
|
||||
|
||||
Этот репозиторий содержит открытую версию Crank:
|
||||
|
||||
- REST API как источник инструментов;
|
||||
- MCP через Streamable HTTP;
|
||||
- веб-интерфейс администратора;
|
||||
- простая авторизация администратора;
|
||||
- ключи агентов для доступа к MCP;
|
||||
- PostgreSQL как основное хранилище;
|
||||
- необязательный Valkey или Redis для служебного кэша.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```text
|
||||
apps/
|
||||
admin-api/ HTTP API для веб-интерфейса
|
||||
mcp-server/ MCP-сервер
|
||||
ui/ веб-интерфейс
|
||||
|
||||
crates/
|
||||
crank-core/ общая модель данных
|
||||
crank-registry/ работа с PostgreSQL
|
||||
crank-runtime/ выполнение REST-инструментов
|
||||
crank-adapter-rest/ REST-адаптер
|
||||
crank-community-auth/ пароли и сессии
|
||||
crank-community-mcp/ слой MCP
|
||||
crank-mapping/ преобразование данных через JSONPath
|
||||
crank-schema/ проверка схем
|
||||
|
||||
deploy/community/
|
||||
docker-compose.yml запуск с внешним PostgreSQL
|
||||
docker-compose.images.yml запуск готовых образов без исходников
|
||||
.env.example пример настроек для серверного запуска из исходников
|
||||
.env.images.example пример настроек для запуска готовых образов
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
Для разработки нужны:
|
||||
|
||||
- Rust toolchain из [`rust-toolchain.toml`](./rust-toolchain.toml);
|
||||
- Node.js и npm;
|
||||
- PostgreSQL;
|
||||
- Docker, если хотите запускать полный стенд контейнерами.
|
||||
|
||||
Быстрее всего поднять окружение так же, как для обычного запуска:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bsodfather/crank-community.git crank
|
||||
cd crank
|
||||
cp .env.example .env
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
Приложения читают настройки из переменных окружения. Можно использовать `.env` через свое окружение разработки, `direnv`, IDE или любой другой привычный способ загрузки переменных.
|
||||
|
||||
Сервер панели управления:
|
||||
|
||||
```bash
|
||||
cargo run -p admin-api
|
||||
```
|
||||
|
||||
MCP-сервер:
|
||||
|
||||
```bash
|
||||
cargo run -p mcp-server
|
||||
```
|
||||
|
||||
Веб-интерфейс:
|
||||
|
||||
```bash
|
||||
cd apps/ui
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Проверки:
|
||||
|
||||
```bash
|
||||
just fmt-check
|
||||
just clippy
|
||||
just test
|
||||
```
|
||||
|
||||
Сборка веб-интерфейса:
|
||||
|
||||
```bash
|
||||
cd apps/ui
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
E2E-проверки:
|
||||
|
||||
```bash
|
||||
cd apps/ui
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
## Документация
|
||||
|
||||
- [Настройки запуска](docs/runtime-config.md)
|
||||
- [Развертывание](docs/deployment.md)
|
||||
- [MCP-интерфейс](docs/mcp-interface.md)
|
||||
- [Английский README](docs/en/README.md)
|
||||
|
||||
## Участие в разработке
|
||||
|
||||
Перед отправкой pull request нужно согласиться с [Contributor License Agreement](CLA.md).
|
||||
|
||||
Правила участия описаны в [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Лицензия
|
||||
|
||||
GNU Affero General Public License v3.0 only
|
||||
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "admin-api"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "admin-api"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
argon2.workspace = true
|
||||
axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
base64.workspace = true
|
||||
crank-community-auth = { path = "../../crates/crank-community-auth" }
|
||||
crank-core = { path = "../../crates/crank-core" }
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
crank-registry = { path = "../../crates/crank-registry" }
|
||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
reqwest.workspace = true
|
||||
serial_test = "3"
|
||||
@@ -0,0 +1,68 @@
|
||||
FROM rust:1.85-bookworm AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY .sqlx ./.sqlx
|
||||
COPY apps/admin-api/Cargo.toml apps/admin-api/Cargo.toml
|
||||
COPY apps/mcp-server/Cargo.toml apps/mcp-server/Cargo.toml
|
||||
COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml
|
||||
COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml
|
||||
COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml
|
||||
COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml
|
||||
COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml
|
||||
COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml
|
||||
|
||||
RUN mkdir -p \
|
||||
apps/admin-api/src \
|
||||
apps/mcp-server/src \
|
||||
crates/crank-core/src \
|
||||
crates/crank-schema/src \
|
||||
crates/crank-mapping/src \
|
||||
crates/crank-registry/src \
|
||||
crates/crank-runtime/src \
|
||||
crates/crank-adapter-rest/src \
|
||||
&& printf 'fn main() {}\n' > apps/admin-api/src/main.rs \
|
||||
&& printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/app/target \
|
||||
SQLX_OFFLINE=true cargo build --release -p admin-api
|
||||
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY .sqlx ./.sqlx
|
||||
COPY apps ./apps
|
||||
COPY crates ./crates
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/app/target \
|
||||
SQLX_OFFLINE=true cargo build --release -p admin-api \
|
||||
&& cp /app/target/release/admin-api /tmp/admin-api
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api
|
||||
|
||||
ENV CRANK_ADMIN_BIND=0.0.0.0:3001
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["admin-api"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
use axum::{
|
||||
extract::{OriginalUri, Request, State},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||
use crank_community_auth::{
|
||||
cleared_session_cookie as build_cleared_session_cookie,
|
||||
create_session_cookie as build_session_cookie, hash_password as community_hash_password,
|
||||
session_cookie as build_session_cookie_header,
|
||||
};
|
||||
use crank_core::{User, UserSessionId, WorkspaceId};
|
||||
use crank_registry::WorkspaceMembershipRecord;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
pub use crank_community_auth::{
|
||||
SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BootstrapAdminConfig {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthSettings {
|
||||
pub session_secret: String,
|
||||
pub password_pepper: String,
|
||||
pub session_ttl_hours: i64,
|
||||
pub cookie_secure: bool,
|
||||
pub bootstrap_admin: BootstrapAdminConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AuthenticatedSession {
|
||||
pub session_id: UserSessionId,
|
||||
pub user: User,
|
||||
pub memberships: Vec<WorkspaceMembershipRecord>,
|
||||
pub current_workspace_id: Option<WorkspaceId>,
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str, pepper: &str) -> Result<String, ApiError> {
|
||||
community_hash_password(password, pepper)
|
||||
.map_err(|error| ApiError::internal(format!("failed to hash password: {error}")))
|
||||
}
|
||||
|
||||
pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, ApiError> {
|
||||
build_session_cookie(settings.session_ttl_hours)
|
||||
.map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}")))
|
||||
}
|
||||
|
||||
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
|
||||
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours)
|
||||
}
|
||||
|
||||
pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> {
|
||||
build_cleared_session_cookie(settings.cookie_secure)
|
||||
}
|
||||
|
||||
pub async fn require_session(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let session = resolve_authenticated_session(state.clone(), &jar).await?;
|
||||
request.extensions_mut().insert(session);
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
pub async fn require_workspace_session(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
original_uri: OriginalUri,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let session = resolve_authenticated_session(state.clone(), &jar).await?;
|
||||
let workspace_id = workspace_id_from_path(original_uri.path())
|
||||
.ok_or_else(|| ApiError::internal("workspace middleware was applied to an invalid path"))?;
|
||||
|
||||
let has_access = state
|
||||
.service
|
||||
.user_has_workspace_access(&session.user.id, &workspace_id)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(ApiError::forbidden("workspace access denied"));
|
||||
}
|
||||
|
||||
request.extensions_mut().insert(session);
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
async fn resolve_authenticated_session(
|
||||
state: AppState,
|
||||
jar: &CookieJar,
|
||||
) -> Result<AuthenticatedSession, ApiError> {
|
||||
let (session_id, session_value) = extract_session_token(jar)
|
||||
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
|
||||
let session = state
|
||||
.service
|
||||
.get_session(&session_id, &session_value)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
|
||||
|
||||
state.service.touch_session(&session_id).await?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
|
||||
let marker = "/api/admin/workspaces/";
|
||||
let (_, suffix) = path.split_once(marker)?;
|
||||
let workspace_id = suffix.split('/').next()?;
|
||||
if workspace_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(WorkspaceId::new(workspace_id))
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use crank_mapping::MappingError;
|
||||
use crank_registry::RegistryError;
|
||||
use crank_runtime::RuntimeError;
|
||||
use crank_schema::SchemaError;
|
||||
use serde_json::{Value, json};
|
||||
use thiserror::Error;
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::storage::StorageError;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("{message}")]
|
||||
Unauthorized {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Forbidden {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Validation {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
NotFound {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Conflict {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
RateLimited {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Internal {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::Unauthorized {
|
||||
message: message.into(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::Forbidden {
|
||||
message: message.into(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validation(message: impl Into<String>) -> Self {
|
||||
Self::Validation {
|
||||
message: message.into(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::Internal {
|
||||
message: message.into(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn rate_limited_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::RateLimited {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validation_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::Validation {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::NotFound {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn conflict_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::Conflict {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn forbidden_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::Forbidden {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
||||
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
|
||||
Self::Validation { .. } => StatusCode::BAD_REQUEST,
|
||||
Self::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
Self::Conflict { .. } => StatusCode::CONFLICT,
|
||||
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unauthorized { .. } => "unauthorized",
|
||||
Self::Forbidden { .. } => "forbidden",
|
||||
Self::Validation { .. } => "validation_error",
|
||||
Self::NotFound { .. } => "not_found",
|
||||
Self::Conflict { .. } => "conflict",
|
||||
Self::RateLimited { .. } => "rate_limited",
|
||||
Self::Internal { .. } => "internal_error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
match &self {
|
||||
Self::Internal { message, .. } => {
|
||||
error!(error_code = self.code(), error_message = %message)
|
||||
}
|
||||
Self::Unauthorized { message, .. }
|
||||
| Self::Forbidden { message, .. }
|
||||
| Self::Validation { message, .. }
|
||||
| Self::NotFound { message, .. }
|
||||
| Self::Conflict { message, .. }
|
||||
| Self::RateLimited { message, .. } => {
|
||||
warn!(error_code = self.code(), error_message = %message)
|
||||
}
|
||||
}
|
||||
|
||||
let mut error = json!({
|
||||
"code": self.code(),
|
||||
"message": self.to_string(),
|
||||
});
|
||||
if let Some(context) = self.context() {
|
||||
error["context"] = context;
|
||||
}
|
||||
|
||||
let body = Json(json!({
|
||||
"error": error
|
||||
}));
|
||||
|
||||
(self.status_code(), body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn context(&self) -> Option<Value> {
|
||||
match self {
|
||||
Self::Unauthorized { context, .. }
|
||||
| Self::Forbidden { context, .. }
|
||||
| Self::Validation { context, .. }
|
||||
| Self::NotFound { context, .. }
|
||||
| Self::Conflict { context, .. }
|
||||
| Self::RateLimited { context, .. }
|
||||
| Self::Internal { context, .. } => context.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RegistryError> for ApiError {
|
||||
fn from(value: RegistryError) -> Self {
|
||||
match value {
|
||||
RegistryError::WorkspaceNotFound { workspace_id } => Self::not_found_with_context(
|
||||
format!("workspace {workspace_id} was not found"),
|
||||
json!({ "workspace_id": workspace_id }),
|
||||
),
|
||||
RegistryError::UserNotFound { user_id } => Self::not_found_with_context(
|
||||
format!("user {user_id} was not found"),
|
||||
json!({ "user_id": user_id }),
|
||||
),
|
||||
RegistryError::MembershipNotFound {
|
||||
workspace_id,
|
||||
user_id,
|
||||
} => Self::not_found_with_context(
|
||||
format!("membership for user {user_id} in workspace {workspace_id} was not found"),
|
||||
json!({
|
||||
"workspace_id": workspace_id,
|
||||
"user_id": user_id,
|
||||
}),
|
||||
),
|
||||
RegistryError::AgentNotFound { agent_id } => Self::not_found_with_context(
|
||||
format!("agent {agent_id} was not found"),
|
||||
json!({ "agent_id": agent_id }),
|
||||
),
|
||||
RegistryError::InvitationNotFound { invitation_id } => Self::not_found_with_context(
|
||||
format!("invitation {invitation_id} was not found"),
|
||||
json!({ "invitation_id": invitation_id }),
|
||||
),
|
||||
RegistryError::PlatformApiKeyNotFound { key_id } => Self::not_found_with_context(
|
||||
format!("platform api key {key_id} was not found"),
|
||||
json!({ "key_id": key_id }),
|
||||
),
|
||||
RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
|
||||
format!("secret {secret_id} was not found"),
|
||||
json!({ "secret_id": secret_id }),
|
||||
),
|
||||
RegistryError::StreamSessionNotFound { session_id } => Self::not_found_with_context(
|
||||
format!("stream session {session_id} was not found"),
|
||||
json!({ "session_id": session_id }),
|
||||
),
|
||||
RegistryError::AsyncJobNotFound { job_id } => Self::not_found_with_context(
|
||||
format!("async job {job_id} was not found"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context(
|
||||
format!("invocation log {log_id} was not found"),
|
||||
json!({ "log_id": log_id }),
|
||||
),
|
||||
RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug,
|
||||
agent_slug,
|
||||
} => Self::not_found_with_context(
|
||||
format!("published agent {workspace_slug}/{agent_slug} was not found"),
|
||||
json!({
|
||||
"workspace_slug": workspace_slug,
|
||||
"agent_slug": agent_slug,
|
||||
}),
|
||||
),
|
||||
RegistryError::OperationNotFound { operation_id } => Self::not_found_with_context(
|
||||
format!("operation {operation_id} was not found"),
|
||||
json!({ "operation_id": operation_id }),
|
||||
),
|
||||
RegistryError::OperationVersionNotFound {
|
||||
operation_id,
|
||||
version,
|
||||
} => Self::not_found_with_context(
|
||||
format!("operation version {version} for {operation_id} was not found"),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"version": version,
|
||||
}),
|
||||
),
|
||||
RegistryError::OperationHasPublishedAgentBindings { operation_id } => {
|
||||
Self::conflict_with_context(
|
||||
format!(
|
||||
"operation {operation_id} cannot be deleted while it is bound to a published agent"
|
||||
),
|
||||
json!({ "operation_id": operation_id }),
|
||||
)
|
||||
}
|
||||
RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context(
|
||||
format!("auth profile {auth_profile_id} was not found"),
|
||||
json!({ "auth_profile_id": auth_profile_id }),
|
||||
),
|
||||
RegistryError::OperationAlreadyExists { operation_id } => Self::conflict_with_context(
|
||||
format!("operation {operation_id} already exists"),
|
||||
json!({ "operation_id": operation_id }),
|
||||
),
|
||||
RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context(
|
||||
format!("workspace with slug {slug} already exists"),
|
||||
json!({ "slug": slug }),
|
||||
),
|
||||
RegistryError::SecretNameAlreadyExists { workspace_id, name } => {
|
||||
Self::conflict_with_context(
|
||||
format!("secret with name {name} already exists in workspace {workspace_id}"),
|
||||
json!({
|
||||
"workspace_id": workspace_id,
|
||||
"name": name,
|
||||
}),
|
||||
)
|
||||
}
|
||||
RegistryError::SecretReferencedByAuthProfile {
|
||||
secret_id,
|
||||
auth_profile_id,
|
||||
} => Self::conflict_with_context(
|
||||
format!("secret {secret_id} is referenced by auth profile {auth_profile_id}"),
|
||||
json!({
|
||||
"secret_id": secret_id,
|
||||
"auth_profile_id": auth_profile_id,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidStreamSessionTransition {
|
||||
session_id,
|
||||
from,
|
||||
to,
|
||||
} => Self::conflict_with_context(
|
||||
format!("invalid stream session transition for {session_id}: {from} -> {to}"),
|
||||
json!({
|
||||
"session_id": session_id,
|
||||
"from": from,
|
||||
"to": to,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidAsyncJobTransition { job_id, from, to } => {
|
||||
Self::conflict_with_context(
|
||||
format!("invalid async job transition for {job_id}: {from} -> {to}"),
|
||||
json!({
|
||||
"job_id": job_id,
|
||||
"from": from,
|
||||
"to": to,
|
||||
}),
|
||||
)
|
||||
}
|
||||
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
|
||||
format!("user with email {email} already exists"),
|
||||
json!({ "email": email }),
|
||||
),
|
||||
RegistryError::InvalidInitialVersion {
|
||||
operation_id,
|
||||
version,
|
||||
} => Self::validation_with_context(
|
||||
format!("operation {operation_id} must start with version 1, got {version}"),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"version": version,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidVersionSequence {
|
||||
operation_id,
|
||||
expected,
|
||||
actual,
|
||||
} => Self::validation_with_context(
|
||||
format!("operation {operation_id} expected next version {expected}, got {actual}"),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"expected": expected,
|
||||
"actual": actual,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidAgentVersionSequence {
|
||||
agent_id,
|
||||
expected,
|
||||
actual,
|
||||
} => Self::validation_with_context(
|
||||
format!("agent {agent_id} expected next version {expected}, got {actual}"),
|
||||
json!({
|
||||
"agent_id": agent_id,
|
||||
"expected": expected,
|
||||
"actual": actual,
|
||||
}),
|
||||
),
|
||||
RegistryError::ImmutableOperationFieldChanged {
|
||||
operation_id,
|
||||
field,
|
||||
} => Self::validation_with_context(
|
||||
format!("operation {operation_id} changed immutable field {field}"),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"field": field,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context(
|
||||
format!("unsupported enum representation for field {field}"),
|
||||
json!({ "field": field }),
|
||||
),
|
||||
RegistryError::InvalidNumericValue { field, value } => Self::validation_with_context(
|
||||
format!("invalid numeric value for field {field}: {value}"),
|
||||
json!({
|
||||
"field": field,
|
||||
"value": value,
|
||||
}),
|
||||
),
|
||||
RegistryError::YamlImportJobNotFound { job_id } => Self::validation_with_context(
|
||||
format!("yaml import job {job_id} was not found"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::Storage(_) | RegistryError::Serialization(_) => {
|
||||
Self::internal(value.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MappingError> for ApiError {
|
||||
fn from(value: MappingError) -> Self {
|
||||
Self::validation(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SchemaError> for ApiError {
|
||||
fn from(value: SchemaError) -> Self {
|
||||
Self::validation(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StorageError> for ApiError {
|
||||
fn from(value: StorageError) -> Self {
|
||||
match value {
|
||||
StorageError::InvalidStorageRef { details } => Self::validation_with_context(
|
||||
format!("invalid storage reference: {details}"),
|
||||
json!({ "details": details }),
|
||||
),
|
||||
StorageError::Io(_) | StorageError::Serialization(_) => {
|
||||
Self::internal(value.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
|
||||
let mut payload = json!({
|
||||
"code": runtime_test_failure_code(error),
|
||||
"message": error.to_string()
|
||||
});
|
||||
if let Some(context) = runtime_error_context(error) {
|
||||
payload["context"] = context;
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
RuntimeError::Schema(_) => "runtime_schema_error",
|
||||
RuntimeError::Mapping(_) => "runtime_mapping_error",
|
||||
RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error",
|
||||
RuntimeError::GrpcAdapter(_) => "runtime_grpc_error",
|
||||
RuntimeError::RestAdapter(_) => "runtime_rest_error",
|
||||
RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error",
|
||||
RuntimeError::SoapAdapter(_) => "runtime_soap_error",
|
||||
RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
||||
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
|
||||
RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"runtime_secret_error"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error",
|
||||
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
|
||||
match error {
|
||||
RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({
|
||||
"field": field,
|
||||
"reason": reason,
|
||||
})),
|
||||
RuntimeError::InvalidStreamingPayload { field, reason } => Some(json!({
|
||||
"field": field,
|
||||
"reason": reason,
|
||||
})),
|
||||
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
"reason": reason,
|
||||
})),
|
||||
RuntimeError::SecretCrypto { operation, details } => Some(json!({
|
||||
"operation": operation,
|
||||
"details": details,
|
||||
})),
|
||||
RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({
|
||||
"auth_profile_id": auth_profile_id,
|
||||
})),
|
||||
RuntimeError::MissingSecret { secret_id } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
})),
|
||||
RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
"version": version,
|
||||
})),
|
||||
RuntimeError::MissingStreamingConfig { operation_id } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
})),
|
||||
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
"mode": mode,
|
||||
})),
|
||||
RuntimeError::UnsupportedProtocol { protocol } => Some(json!({
|
||||
"protocol": protocol,
|
||||
})),
|
||||
RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({
|
||||
"kind": kind,
|
||||
"limit": limit,
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{ApiError, runtime_error_context, runtime_test_failure};
|
||||
use crank_registry::RegistryError;
|
||||
use crank_runtime::RuntimeError;
|
||||
|
||||
#[test]
|
||||
fn runtime_test_failure_includes_structured_context() {
|
||||
let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest {
|
||||
field: "request.headers".to_owned(),
|
||||
reason: "must be an object".to_owned(),
|
||||
});
|
||||
|
||||
assert_eq!(payload["code"], "runtime_request_error");
|
||||
assert_eq!(
|
||||
payload["context"],
|
||||
json!({
|
||||
"field": "request.headers",
|
||||
"reason": "must be an object"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_error_context_includes_secret_crypto_operation() {
|
||||
let context = runtime_error_context(&RuntimeError::SecretCrypto {
|
||||
operation: "decode secret envelope",
|
||||
details: "bad base64".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
context,
|
||||
json!({
|
||||
"operation": "decode secret envelope",
|
||||
"details": "bad base64"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_test_failure_includes_runtime_overload_context() {
|
||||
let payload = runtime_test_failure(&RuntimeError::ConcurrencyLimitExceeded {
|
||||
kind: "window",
|
||||
limit: 16,
|
||||
});
|
||||
|
||||
assert_eq!(payload["code"], "runtime_overloaded");
|
||||
assert_eq!(
|
||||
payload["context"],
|
||||
json!({
|
||||
"kind": "window",
|
||||
"limit": 16
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_errors_preserve_structured_context_in_api_error() {
|
||||
let error = ApiError::from(RegistryError::InvalidAsyncJobTransition {
|
||||
job_id: "job_123".to_owned(),
|
||||
from: "running".to_owned(),
|
||||
to: "completed".to_owned(),
|
||||
});
|
||||
|
||||
match error {
|
||||
ApiError::Conflict { context, .. } => {
|
||||
assert_eq!(
|
||||
context,
|
||||
Some(json!({
|
||||
"job_id": "job_123",
|
||||
"from": "running",
|
||||
"to": "completed"
|
||||
}))
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected error variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod rate_limit;
|
||||
pub mod request_context;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
@@ -0,0 +1,156 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig},
|
||||
service::AdminServiceBuilder,
|
||||
state::AppState,
|
||||
};
|
||||
use crank_community_auth::PasswordIdentityProvider;
|
||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||
use crank_runtime::{
|
||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||
RuntimeLimits, SecretCrypto, community_default,
|
||||
};
|
||||
use sqlx::postgres::PgConnectOptions;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
env::var("CRANK_LOG_LEVEL").unwrap_or_else(|_| "admin_api=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let storage_root = PathBuf::from(
|
||||
env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()),
|
||||
);
|
||||
let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into());
|
||||
let base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into());
|
||||
let socket_addr: SocketAddr = bind_addr.parse()?;
|
||||
let pool_config = PostgresPoolConfig::from_env()?;
|
||||
let registry = PostgresRegistry::connect_with_options_and_pool_config(
|
||||
database_options_from_env()?,
|
||||
pool_config,
|
||||
)
|
||||
.await?;
|
||||
let auth_settings = AuthSettings {
|
||||
session_secret: env::var("CRANK_SESSION_SECRET")?,
|
||||
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?,
|
||||
session_ttl_hours: env::var("CRANK_SESSION_TTL_HOURS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(24),
|
||||
cookie_secure: base_url.starts_with("https://"),
|
||||
bootstrap_admin: BootstrapAdminConfig {
|
||||
email: env::var("CRANK_BOOTSTRAP_ADMIN_EMAIL")?,
|
||||
password: env::var("CRANK_BOOTSTRAP_ADMIN_PASSWORD")?,
|
||||
display_name: env::var("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME")
|
||||
.unwrap_or_else(|_| "Crank Owner".into()),
|
||||
},
|
||||
};
|
||||
let runtime_limits = RuntimeLimits::from_env()?;
|
||||
let cache_config = RuntimeCacheConfig::from_env()?;
|
||||
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
||||
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||
let runtime = community_default()
|
||||
.with_limits(runtime_limits)
|
||||
.with_response_cache(cache_stores.response.clone())
|
||||
.build();
|
||||
let identity_provider =
|
||||
PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone());
|
||||
let service = AdminServiceBuilder::new(
|
||||
registry,
|
||||
storage_root,
|
||||
auth_settings,
|
||||
secret_crypto,
|
||||
runtime,
|
||||
)
|
||||
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
||||
.build();
|
||||
service.bootstrap_admin_user().await?;
|
||||
if env_flag("CRANK_DEMO_SEED") {
|
||||
service.seed_demo_assets().await?;
|
||||
}
|
||||
let state = AppState {
|
||||
service,
|
||||
api_rate_limiter: if cache_config.backend.is_external() {
|
||||
RequestRateLimiter::new_shared(api_rate_limit, cache_stores.rate_limit.clone())
|
||||
} else {
|
||||
RequestRateLimiter::new(api_rate_limit)
|
||||
},
|
||||
};
|
||||
let app = build_app(state);
|
||||
let listener = TcpListener::bind(socket_addr).await?;
|
||||
|
||||
info!(
|
||||
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
|
||||
runtime_max_concurrent_window = runtime_limits.max_concurrent_window,
|
||||
runtime_max_concurrent_sessions = runtime_limits.max_concurrent_sessions,
|
||||
runtime_max_concurrent_jobs = runtime_limits.max_concurrent_jobs,
|
||||
admin_rate_limit_rps = api_rate_limit.requests_per_second,
|
||||
admin_rate_limit_burst = api_rate_limit.burst,
|
||||
cache_backend = %cache_config.backend,
|
||||
max_connections = pool_config.max_connections,
|
||||
min_connections = pool_config.min_connections,
|
||||
acquire_timeout_ms = pool_config.acquire_timeout_ms,
|
||||
idle_timeout_ms = pool_config.idle_timeout_ms,
|
||||
max_lifetime_ms = pool_config.max_lifetime_ms,
|
||||
"postgres pool configured"
|
||||
);
|
||||
info!("admin-api listening on {}", socket_addr);
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
matches!(
|
||||
env::var(name)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref(),
|
||||
Some("1" | "true" | "yes" | "on")
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_api_rate_limit_config_from_env()
|
||||
-> Result<RequestRateLimitConfig, Box<dyn std::error::Error>> {
|
||||
let requests_per_second = env::var("CRANK_ADMIN_RATE_LIMIT_RPS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(30);
|
||||
let burst = env::var("CRANK_ADMIN_RATE_LIMIT_BURST")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(60);
|
||||
|
||||
Ok(RequestRateLimitConfig::new(requests_per_second, burst)?)
|
||||
}
|
||||
|
||||
fn database_options_from_env() -> Result<PgConnectOptions, Box<dyn std::error::Error>> {
|
||||
if let Ok(database_url) = env::var("CRANK_DATABASE_URL") {
|
||||
return Ok(database_url.parse::<PgConnectOptions>()?);
|
||||
}
|
||||
|
||||
let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into());
|
||||
let port = env::var("POSTGRES_PORT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(5432);
|
||||
let database = env::var("POSTGRES_DB").unwrap_or_else(|_| "crank".into());
|
||||
let username = env::var("POSTGRES_USER").unwrap_or_else(|_| "crank".into());
|
||||
let password = env::var("POSTGRES_PASSWORD").unwrap_or_else(|_| "crank".into());
|
||||
|
||||
Ok(PgConnectOptions::new()
|
||||
.host(&host)
|
||||
.port(port)
|
||||
.database(&database)
|
||||
.username(&username)
|
||||
.password(&password))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header::{COOKIE, HeaderMap},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use crank_runtime::RateLimitRejection;
|
||||
|
||||
use crate::{auth::SESSION_COOKIE_NAME, error::ApiError, state::AppState};
|
||||
|
||||
pub async fn apply_api_rate_limit(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let key = rate_limit_key(request.headers(), request.uri().path());
|
||||
if let Err(rejection) = state.api_rate_limiter.check(&key).await {
|
||||
return Err(ApiError::rate_limited_with_context(
|
||||
"request rate limit exceeded",
|
||||
rejection_context(rejection),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"retry_after_ms": rejection.retry_after_ms,
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_limit_key(headers: &HeaderMap, path: &str) -> String {
|
||||
if let Some(session_id) = session_id_from_headers(headers) {
|
||||
return format!("session:{session_id}");
|
||||
}
|
||||
|
||||
if let Some(forwarded_for) = header_value(headers, "x-forwarded-for") {
|
||||
let ip = forwarded_for
|
||||
.split(',')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("unknown");
|
||||
return format!("ip:{ip}");
|
||||
}
|
||||
|
||||
if let Some(real_ip) = header_value(headers, "x-real-ip") {
|
||||
return format!("ip:{real_ip}");
|
||||
}
|
||||
|
||||
format!("anonymous:{path}")
|
||||
}
|
||||
|
||||
fn session_id_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
let cookies = headers.get(COOKIE)?.to_str().ok()?;
|
||||
for part in cookies.split(';') {
|
||||
let (name, value) = part.trim().split_once('=')?;
|
||||
if name != SESSION_COOKIE_NAME {
|
||||
continue;
|
||||
}
|
||||
let (session_id, _) = value.split_once('.')?;
|
||||
if !session_id.is_empty() {
|
||||
return Some(session_id.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
|
||||
headers.get(name)?.to_str().ok().map(str::trim)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
||||
|
||||
use super::rate_limit_key;
|
||||
|
||||
#[test]
|
||||
fn keys_by_session_cookie_first() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
COOKIE,
|
||||
HeaderValue::from_static("theme=dark; crank_session=sess_123.secret_456"),
|
||||
);
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login"),
|
||||
"session:sess_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_forwarded_ip() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("10.0.0.5, 10.0.0.6"),
|
||||
);
|
||||
|
||||
assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{HeaderMap, HeaderName, HeaderValue},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
|
||||
const MAX_REQUEST_ID_LEN: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestContext {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
pub async fn apply_request_context(mut request: Request, next: Next) -> Response {
|
||||
let context = RequestContext {
|
||||
request_id: resolve_request_id(request.headers()),
|
||||
};
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_owned();
|
||||
request.extensions_mut().insert(context.clone());
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
info!(
|
||||
request_id = %context.request_id,
|
||||
method = %method,
|
||||
path,
|
||||
status = response.status().as_u16(),
|
||||
"admin request completed"
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&context.request_id) {
|
||||
response.headers_mut().insert(REQUEST_ID_HEADER, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn resolve_request_id(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get(&REQUEST_ID_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| is_valid_request_id(value))
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| Uuid::now_v7().to_string())
|
||||
}
|
||||
|
||||
fn is_valid_request_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_REQUEST_ID_LEN
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use reqwest::Client;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
||||
|
||||
use super::{REQUEST_ID_HEADER, apply_request_context, is_valid_request_id};
|
||||
|
||||
#[test]
|
||||
fn accepts_visible_ascii_request_ids() {
|
||||
assert!(is_valid_request_id("req_test_123"));
|
||||
assert!(is_valid_request_id("trace-123/abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_or_control_request_ids() {
|
||||
assert!(!is_valid_request_id(""));
|
||||
assert!(!is_valid_request_id("bad value"));
|
||||
assert!(!is_valid_request_id("bad\nvalue"));
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_request_completion_with_request_id() {
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = tracing_subscriber::registry().with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(writer.clone())
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.with_filter(LevelFilter::INFO),
|
||||
);
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let app = Router::new()
|
||||
.route("/probe", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(apply_request_context));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let response = Client::new()
|
||||
.get(format!("http://{address}/probe"))
|
||||
.header(REQUEST_ID_HEADER.as_str(), "req_admin_trace_123")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[REQUEST_ID_HEADER.as_str()]
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"req_admin_trace_123"
|
||||
);
|
||||
|
||||
let logs = writer.output();
|
||||
assert!(logs.contains("admin request completed"));
|
||||
assert!(logs.contains("req_admin_trace_123"));
|
||||
assert!(logs.contains("GET"));
|
||||
assert!(logs.contains("/probe"));
|
||||
assert!(logs.contains("status=200"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub mod access;
|
||||
pub mod agents;
|
||||
pub mod auth;
|
||||
pub mod auth_profiles;
|
||||
pub mod capabilities;
|
||||
pub mod machine_auth;
|
||||
pub mod observability;
|
||||
pub mod operations;
|
||||
pub mod secrets;
|
||||
pub mod streaming;
|
||||
pub mod workspaces;
|
||||
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
pub async fn health() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"service": "admin-api",
|
||||
"status": "ok"
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use crank_core::{
|
||||
AuditActor, AuditEvent, AuditEventId, AuditTarget, AuditTargetKind, PolicyAction,
|
||||
PolicyDecision, PolicyScope, SessionActor,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{InvitationPayload, UpdateMembershipPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceInvitationPath {
|
||||
pub workspace_id: String,
|
||||
pub invitation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceMembershipPath {
|
||||
pub workspace_id: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_memberships(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_memberships(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn update_membership(
|
||||
Path(path): Path<WorkspaceMembershipPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<UpdateMembershipPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||
enforce_workspace_policy(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
PolicyAction::WriteWorkspaceAccess,
|
||||
)?;
|
||||
let items = state
|
||||
.service
|
||||
.update_membership_role(
|
||||
&workspace_id,
|
||||
&session.user.id,
|
||||
&path.user_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
record_access_audit(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
"membership.role_updated",
|
||||
AuditTargetKind::Membership,
|
||||
path.user_id.clone(),
|
||||
json!({ "user_id": path.user_id }),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn delete_membership(
|
||||
Path(path): Path<WorkspaceMembershipPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||
enforce_workspace_policy(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
PolicyAction::WriteWorkspaceAccess,
|
||||
)?;
|
||||
state
|
||||
.service
|
||||
.remove_membership(
|
||||
&workspace_id,
|
||||
&session.user.id,
|
||||
&path.user_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
record_access_audit(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
"membership.removed",
|
||||
AuditTargetKind::Membership,
|
||||
path.user_id.clone(),
|
||||
json!({ "user_id": path.user_id }),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_invitations(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_invitations(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_invitation(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<InvitationPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||
enforce_workspace_policy(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
PolicyAction::WriteWorkspaceAccess,
|
||||
)?;
|
||||
let created = state
|
||||
.service
|
||||
.create_invitation(&workspace_id, payload)
|
||||
.await?;
|
||||
record_access_audit(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
"invitation.created",
|
||||
AuditTargetKind::Invitation,
|
||||
created.invitation.invitation.id.as_str().to_owned(),
|
||||
json!({ "invitation_id": created.invitation.invitation.id.as_str() }),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn delete_invitation(
|
||||
Path(path): Path<WorkspaceInvitationPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||
enforce_workspace_policy(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
PolicyAction::WriteWorkspaceAccess,
|
||||
)?;
|
||||
state
|
||||
.service
|
||||
.delete_invitation(&workspace_id, &path.invitation_id.as_str().into())
|
||||
.await?;
|
||||
record_access_audit(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
"invitation.deleted",
|
||||
AuditTargetKind::Invitation,
|
||||
path.invitation_id.clone(),
|
||||
json!({ "invitation_id": path.invitation_id }),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn export_workspace(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let exported = state
|
||||
.service
|
||||
.export_workspace(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!(exported)))
|
||||
}
|
||||
|
||||
pub async fn delete_workspace(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
|
||||
enforce_workspace_policy(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
PolicyAction::WriteWorkspace,
|
||||
)?;
|
||||
state
|
||||
.service
|
||||
.delete_workspace(&workspace_id, &session.user.id)
|
||||
.await?;
|
||||
record_access_audit(
|
||||
&state,
|
||||
&session,
|
||||
&workspace_id,
|
||||
"workspace.deleted",
|
||||
AuditTargetKind::Workspace,
|
||||
workspace_id.as_str().to_owned(),
|
||||
json!({ "workspace_id": workspace_id.as_str() }),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn enforce_workspace_policy(
|
||||
state: &AppState,
|
||||
session: &AuthenticatedSession,
|
||||
workspace_id: &crank_core::WorkspaceId,
|
||||
action: PolicyAction,
|
||||
) -> Result<(), ApiError> {
|
||||
let membership = session
|
||||
.memberships
|
||||
.iter()
|
||||
.find(|membership| membership.workspace.id == *workspace_id)
|
||||
.ok_or_else(|| ApiError::forbidden("workspace access denied"))?;
|
||||
let actor = SessionActor {
|
||||
user_id: session.user.id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
role: membership.role,
|
||||
};
|
||||
|
||||
match state.service.policy_engine().check(
|
||||
&actor,
|
||||
action,
|
||||
PolicyScope::Workspace(workspace_id.clone()),
|
||||
) {
|
||||
PolicyDecision::Allow => Ok(()),
|
||||
PolicyDecision::Deny { reason } => Err(ApiError::forbidden(reason)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_access_audit(
|
||||
state: &AppState,
|
||||
session: &AuthenticatedSession,
|
||||
workspace_id: &crank_core::WorkspaceId,
|
||||
action: &str,
|
||||
target_kind: AuditTargetKind,
|
||||
target_id: String,
|
||||
payload: Value,
|
||||
) -> Result<(), ApiError> {
|
||||
state
|
||||
.service
|
||||
.audit_sink()
|
||||
.record(AuditEvent {
|
||||
id: AuditEventId::new(format!("audit_{}", Uuid::now_v7().simple())),
|
||||
occurred_at: OffsetDateTime::now_utc(),
|
||||
actor: AuditActor {
|
||||
user_id: session.user.id.clone(),
|
||||
email: session.user.email.clone(),
|
||||
session_id: Some(session.session_id.clone()),
|
||||
},
|
||||
action: action.to_owned(),
|
||||
target: AuditTarget {
|
||||
workspace_id: workspace_id.clone(),
|
||||
kind: target_kind,
|
||||
id: target_id,
|
||||
},
|
||||
payload,
|
||||
source_ip: None,
|
||||
user_agent: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ApiError::internal(format!("failed to record audit event: {error}")))
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||
UpdateAgentPayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceAgentPath {
|
||||
pub workspace_id: String,
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceAgentVersionPath {
|
||||
pub workspace_id: String,
|
||||
pub agent_id: String,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceAgentPlatformApiKeyPath {
|
||||
pub workspace_id: String,
|
||||
pub agent_id: String,
|
||||
pub key_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_agents(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_agents(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_agent(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AgentPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let created = state
|
||||
.service
|
||||
.create_agent(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn get_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let agent = state
|
||||
.service
|
||||
.get_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(agent)))
|
||||
}
|
||||
|
||||
pub async fn update_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateAgentPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let updated = state
|
||||
.service
|
||||
.update_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn delete_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let deleted = state
|
||||
.service
|
||||
.delete_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(deleted)))
|
||||
}
|
||||
|
||||
pub async fn get_agent_version(
|
||||
Path(path): Path<WorkspaceAgentVersionPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let version = state
|
||||
.service
|
||||
.get_agent_version(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
path.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(version)))
|
||||
}
|
||||
|
||||
pub async fn save_agent_bindings(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<Vec<AgentBindingPayload>>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let record = state
|
||||
.service
|
||||
.save_agent_bindings(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(record)))
|
||||
}
|
||||
|
||||
pub async fn publish_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PublishPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let published = state
|
||||
.service
|
||||
.publish_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(published)))
|
||||
}
|
||||
|
||||
pub async fn unpublish_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let updated = state
|
||||
.service
|
||||
.unpublish_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn archive_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let updated = state
|
||||
.service
|
||||
.archive_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn list_agent_platform_api_keys(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_agent_platform_api_keys(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PlatformApiKeyPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let created = state
|
||||
.service
|
||||
.create_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn revoke_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.revoke_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn delete_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.delete_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use axum::{Extension, Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
|
||||
error::ApiError,
|
||||
service::{
|
||||
ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(payload): Json<LoginPayload>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let (session_data, session) = state.service.login(payload).await?;
|
||||
let cookie_value = format!(
|
||||
"{}.{}",
|
||||
session_data.session_id.as_str(),
|
||||
session_data.value
|
||||
);
|
||||
let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value));
|
||||
|
||||
Ok((jar, Json(serde_json::json!(session))))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if let Some((session_id, session_value)) = extract_session_token(&jar) {
|
||||
state.service.logout(&session_id, &session_value).await?;
|
||||
}
|
||||
|
||||
let jar = jar.remove(cleared_session_cookie(state.service.auth_settings()));
|
||||
Ok((jar, StatusCode::NO_CONTENT))
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let (session_id, session_value) = extract_session_token(&jar)
|
||||
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
|
||||
let session = state
|
||||
.service
|
||||
.session_response(&session_id, &session_value)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
|
||||
|
||||
Ok(Json(serde_json::json!(session)))
|
||||
}
|
||||
|
||||
pub async fn get_profile(
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
Ok(Json(json!({
|
||||
"user": session.user,
|
||||
"memberships": session.memberships,
|
||||
"current_workspace_id": session.current_workspace_id
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<UpdateProfilePayload>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let updated = state
|
||||
.service
|
||||
.update_profile(
|
||||
&session.user.id,
|
||||
session.current_workspace_id.as_ref(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn update_current_workspace(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<UpdateCurrentWorkspacePayload>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let updated = state
|
||||
.service
|
||||
.set_current_workspace(
|
||||
&session.session_id,
|
||||
&session.user.id,
|
||||
&payload.workspace_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<ChangePasswordPayload>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.change_password(&session.user.id, payload)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{error::ApiError, service::AuthProfilePayload, state::AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceAuthProfilePath {
|
||||
pub workspace_id: String,
|
||||
pub auth_profile_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_auth_profiles(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_auth_profiles(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_auth_profile(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AuthProfilePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let profile = state
|
||||
.service
|
||||
.create_auth_profile(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(profile)))
|
||||
}
|
||||
|
||||
pub async fn get_auth_profile(
|
||||
Path(path): Path<WorkspaceAuthProfilePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let profile = state
|
||||
.service
|
||||
.get_auth_profile(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.auth_profile_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(profile)))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use axum::{Json, extract::State};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn get_capabilities(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!(state.service.capability_profile().capabilities()))
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use axum::{Json, extract::State};
|
||||
use crank_core::{
|
||||
IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MachineAccessMode, MembershipRole,
|
||||
TokenIssuerActor, TokenIssuerError, UserId, WorkspaceId,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
pub async fn issue_agent_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<IssueAgentTokenRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let edition = state.service.capability_profile().capabilities().edition;
|
||||
let grant_type = payload.grant_type.clone();
|
||||
let response = state
|
||||
.service
|
||||
.token_issuer()
|
||||
.issue_short_lived(payload, &community_token_issuer_actor())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_token_issuer_error(
|
||||
error,
|
||||
edition,
|
||||
MachineAccessMode::ShortLivedToken,
|
||||
json!({
|
||||
"grant_type": grant_type,
|
||||
"upgrade_required": true,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(serde_json::json!(response)))
|
||||
}
|
||||
|
||||
pub async fn issue_one_time_agent_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<IssueOneTimeAgentTokenRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let edition = state.service.capability_profile().capabilities().edition;
|
||||
let operation_id = payload.operation_id.as_str().to_owned();
|
||||
let response = state
|
||||
.service
|
||||
.token_issuer()
|
||||
.issue_one_time(payload, &community_token_issuer_actor())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_token_issuer_error(
|
||||
error,
|
||||
edition,
|
||||
MachineAccessMode::OneTimeToken,
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"upgrade_required": true,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(serde_json::json!(response)))
|
||||
}
|
||||
|
||||
fn community_token_issuer_actor() -> TokenIssuerActor {
|
||||
TokenIssuerActor {
|
||||
user_id: UserId::new("user_community_public"),
|
||||
workspace_id: WorkspaceId::new("ws_community_public"),
|
||||
role: MembershipRole::Viewer,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_token_issuer_error(
|
||||
error: TokenIssuerError,
|
||||
edition: crank_core::ProductEdition,
|
||||
machine_access_mode: MachineAccessMode,
|
||||
extra_context: serde_json::Value,
|
||||
) -> ApiError {
|
||||
match error {
|
||||
TokenIssuerError::NotSupportedInEdition => ApiError::forbidden_with_context(
|
||||
match machine_access_mode {
|
||||
MachineAccessMode::ShortLivedToken => {
|
||||
"short-lived machine access is not available in Community"
|
||||
}
|
||||
MachineAccessMode::OneTimeToken => {
|
||||
"one-time machine access is not available in Community"
|
||||
}
|
||||
MachineAccessMode::StaticAgentKey => {
|
||||
"static agent key machine access is not available for token issue"
|
||||
}
|
||||
},
|
||||
merge_machine_auth_context(edition, machine_access_mode, extra_context),
|
||||
),
|
||||
TokenIssuerError::InvalidGrant(reason) => ApiError::validation_with_context(
|
||||
"invalid machine token grant",
|
||||
json!({ "reason": reason }),
|
||||
),
|
||||
TokenIssuerError::AgentKeyUnknown => ApiError::validation("agent key is unknown"),
|
||||
TokenIssuerError::OperationNotStrict => ApiError::validation("operation is not strict"),
|
||||
TokenIssuerError::OperationNotPublishedForAgent => {
|
||||
ApiError::validation("operation is not published for agent")
|
||||
}
|
||||
TokenIssuerError::RegistryFailure(details) => {
|
||||
ApiError::internal(format!("token issuer registry failure: {details}"))
|
||||
}
|
||||
TokenIssuerError::ReplayGuardFailure(details) => {
|
||||
ApiError::internal(format!("token issuer replay guard failure: {details}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_machine_auth_context(
|
||||
edition: crank_core::ProductEdition,
|
||||
machine_access_mode: MachineAccessMode,
|
||||
extra_context: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
let mut context = json!({
|
||||
"edition": edition,
|
||||
"machine_access_mode": machine_access_mode,
|
||||
});
|
||||
|
||||
if let (Some(base), Some(extra)) = (context.as_object_mut(), extra_context.as_object()) {
|
||||
for (key, value) in extra {
|
||||
base.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
context
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
routes::access::WorkspacePath,
|
||||
service::{LogsQuery, UsageRequestQuery},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WorkspaceLogPath {
|
||||
pub workspace_id: String,
|
||||
pub log_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WorkspaceOperationUsagePath {
|
||||
pub workspace_id: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WorkspaceAgentUsagePath {
|
||||
pub workspace_id: String,
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_logs(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<LogsQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_logs(&path.workspace_id.as_str().into(), query)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn get_log(
|
||||
Path(path): Path<WorkspaceLogPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let item = state
|
||||
.service
|
||||
.get_log(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.log_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(item)))
|
||||
}
|
||||
|
||||
pub async fn get_usage(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<UsageRequestQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let usage = state
|
||||
.service
|
||||
.get_usage_overview(&path.workspace_id.as_str().into(), query)
|
||||
.await?;
|
||||
Ok(Json(json!(usage)))
|
||||
}
|
||||
|
||||
pub async fn get_operation_usage(
|
||||
Path(path): Path<WorkspaceOperationUsagePath>,
|
||||
Query(query): Query<UsageRequestQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let usage = state
|
||||
.service
|
||||
.get_operation_usage(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
query,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(usage)))
|
||||
}
|
||||
|
||||
pub async fn get_agent_usage(
|
||||
Path(path): Path<WorkspaceAgentUsagePath>,
|
||||
Query(query): Query<UsageRequestQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let usage = state
|
||||
.service
|
||||
.get_agent_usage(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
query,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(usage)))
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
request_context::RequestContext,
|
||||
service::{
|
||||
ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload,
|
||||
PublishPayload, TestRunPayload, UpdateOperationPayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceOperationPath {
|
||||
pub workspace_id: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceOperationVersionPath {
|
||||
pub workspace_id: String,
|
||||
pub operation_id: String,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
pub async fn list_operations(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_operations(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
let total = items.len();
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
"page": 1,
|
||||
"page_size": total,
|
||||
"total": total
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn create_operation(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<OperationPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let created = state
|
||||
.service
|
||||
.create_operation(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn get_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let operation = state
|
||||
.service
|
||||
.get_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(operation)))
|
||||
}
|
||||
|
||||
pub async fn update_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateOperationPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let result = state
|
||||
.service
|
||||
.update_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(result)))
|
||||
}
|
||||
|
||||
pub async fn delete_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let result = state
|
||||
.service
|
||||
.delete_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(result)))
|
||||
}
|
||||
|
||||
pub async fn get_operation_version(
|
||||
Path(path): Path<WorkspaceOperationVersionPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let version = state
|
||||
.service
|
||||
.get_operation_version(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
path.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(version)))
|
||||
}
|
||||
|
||||
pub async fn create_version(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<NewVersionPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let created = state
|
||||
.service
|
||||
.create_version(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn publish_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PublishPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let published = state
|
||||
.service
|
||||
.publish_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(published)))
|
||||
}
|
||||
|
||||
pub async fn archive_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let archived = state
|
||||
.service
|
||||
.archive_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(archived)))
|
||||
}
|
||||
|
||||
pub async fn run_test(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<TestRunPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let result = state
|
||||
.service
|
||||
.run_test(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
&request_context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(result)))
|
||||
}
|
||||
|
||||
pub async fn upload_input_json(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let sample = state
|
||||
.service
|
||||
.save_json_sample(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
crank_registry::SampleKind::InputJson,
|
||||
&payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"sample_id": sample.id.as_str(),
|
||||
"sample_kind": sample.sample_kind,
|
||||
"version": sample.version
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn upload_output_json(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let sample = state
|
||||
.service
|
||||
.save_json_sample(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
crank_registry::SampleKind::OutputJson,
|
||||
&payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"sample_id": sample.id.as_str(),
|
||||
"sample_kind": sample.sample_kind,
|
||||
"version": sample.version
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn generate_draft(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<GenerateDraftPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let draft = state
|
||||
.service
|
||||
.generate_draft(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(draft)))
|
||||
}
|
||||
|
||||
pub async fn export_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
Query(query): Query<ExportQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let yaml = state
|
||||
.service
|
||||
.export_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
query,
|
||||
)
|
||||
.await?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
header::HeaderValue::from_static("application/yaml"),
|
||||
);
|
||||
|
||||
Ok((StatusCode::OK, headers, yaml))
|
||||
}
|
||||
|
||||
pub async fn import_operation(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<ImportQuery>,
|
||||
State(state): State<AppState>,
|
||||
body: String,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let imported = state
|
||||
.service
|
||||
.import_operation(&path.workspace_id.as_str().into(), query, &body)
|
||||
.await?;
|
||||
Ok(Json(json!(imported)))
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{RotateSecretPayload, SecretPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceSecretPath {
|
||||
pub workspace_id: String,
|
||||
pub secret_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_secrets(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_secrets(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_secret(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<SecretPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let secret = state
|
||||
.service
|
||||
.create_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
Some(&session.user.id),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
}
|
||||
|
||||
pub async fn get_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let secret = state
|
||||
.service
|
||||
.get_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
}
|
||||
|
||||
pub async fn rotate_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<RotateSecretPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let secret = state
|
||||
.service
|
||||
.rotate_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
Some(&session.user.id),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
}
|
||||
|
||||
pub async fn delete_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
state
|
||||
.service
|
||||
.delete_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{error::ApiError, routes::access::WorkspacePath, state::AppState};
|
||||
|
||||
pub async fn list_protocol_capabilities(
|
||||
Path(_path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
Ok(Json(json!({
|
||||
"items": state.service.list_protocol_capabilities().await
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{UpdateWorkspacePayload, WorkspacePayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn list_workspaces(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_workspaces_for_user(&session.user.id)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<WorkspacePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = state
|
||||
.service
|
||||
.create_workspace(&session.user.id, payload)
|
||||
.await?;
|
||||
Ok(Json(json!(workspace)))
|
||||
}
|
||||
|
||||
pub async fn get_workspace(
|
||||
Path(workspace_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = state
|
||||
.service
|
||||
.get_workspace(&workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!(workspace)))
|
||||
}
|
||||
|
||||
pub async fn update_workspace(
|
||||
Path(workspace_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateWorkspacePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = state
|
||||
.service
|
||||
.update_workspace(&workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(workspace)))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
use crate::service::AdminService;
|
||||
use crank_runtime::RequestRateLimiter;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub service: AdminService,
|
||||
pub api_rate_limiter: RequestRateLimiter,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crank_core::{OperationId, SampleId};
|
||||
use crank_registry::SampleKind;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalArtifactStorage {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum StorageError {
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
#[error("invalid storage reference: {details}")]
|
||||
InvalidStorageRef { details: String },
|
||||
}
|
||||
|
||||
impl LocalArtifactStorage {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
}
|
||||
|
||||
pub async fn write_json_sample(
|
||||
&self,
|
||||
operation_id: &OperationId,
|
||||
version: u32,
|
||||
sample_kind: SampleKind,
|
||||
sample_id: &SampleId,
|
||||
payload: &Value,
|
||||
) -> Result<String, StorageError> {
|
||||
let file_name = match sample_kind {
|
||||
SampleKind::InputJson => "input.json",
|
||||
SampleKind::OutputJson => "output.json",
|
||||
SampleKind::YamlImportSource => "source.json",
|
||||
};
|
||||
let path = self
|
||||
.root
|
||||
.join("samples")
|
||||
.join(operation_id.as_str())
|
||||
.join(format!("v{version}"))
|
||||
.join(format!("{}_{}", sample_id.as_str(), file_name));
|
||||
|
||||
write_json_file(&path, payload).await?;
|
||||
|
||||
Ok(to_storage_ref(&path))
|
||||
}
|
||||
|
||||
pub async fn read_json(&self, storage_ref: &str) -> Result<Value, StorageError> {
|
||||
let path = from_storage_ref(storage_ref)?;
|
||||
let bytes = fs::read(path).await?;
|
||||
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_json_file(path: &Path, payload: &Value) -> Result<(), StorageError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
let bytes = serde_json::to_vec_pretty(payload)?;
|
||||
fs::write(path, bytes).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_storage_ref(path: &Path) -> String {
|
||||
format!("file://{}", path.display())
|
||||
}
|
||||
|
||||
fn from_storage_ref(storage_ref: &str) -> Result<PathBuf, StorageError> {
|
||||
let Some(path) = storage_ref.strip_prefix("file://") else {
|
||||
return Err(StorageError::InvalidStorageRef {
|
||||
details: storage_ref.to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "mcp-server"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "mcp-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
axum.workspace = true
|
||||
base64.workspace = true
|
||||
crank-community-mcp = { path = "../../crates/crank-community-mcp" }
|
||||
crank-core = { path = "../../crates/crank-core" }
|
||||
crank-registry = { path = "../../crates/crank-registry" }
|
||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
futures-util = "0.3"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
reqwest.workspace = true
|
||||
@@ -0,0 +1,68 @@
|
||||
FROM rust:1.85-bookworm AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY .sqlx ./.sqlx
|
||||
COPY apps/admin-api/Cargo.toml apps/admin-api/Cargo.toml
|
||||
COPY apps/mcp-server/Cargo.toml apps/mcp-server/Cargo.toml
|
||||
COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml
|
||||
COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml
|
||||
COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml
|
||||
COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml
|
||||
COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml
|
||||
COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml
|
||||
|
||||
RUN mkdir -p \
|
||||
apps/admin-api/src \
|
||||
apps/mcp-server/src \
|
||||
crates/crank-core/src \
|
||||
crates/crank-schema/src \
|
||||
crates/crank-mapping/src \
|
||||
crates/crank-registry/src \
|
||||
crates/crank-runtime/src \
|
||||
crates/crank-adapter-rest/src \
|
||||
&& printf 'fn main() {}\n' > apps/admin-api/src/main.rs \
|
||||
&& printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/app/target \
|
||||
SQLX_OFFLINE=true cargo build --release -p mcp-server
|
||||
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY .sqlx ./.sqlx
|
||||
COPY apps ./apps
|
||||
COPY crates ./crates
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/app/target \
|
||||
SQLX_OFFLINE=true cargo build --release -p mcp-server \
|
||||
&& cp /app/target/release/mcp-server /tmp/mcp-server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /tmp/mcp-server /usr/local/bin/mcp-server
|
||||
|
||||
ENV CRANK_MCP_BIND=0.0.0.0:3002
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
CMD ["mcp-server"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY apps/ui/package.json apps/ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY apps/ui ./
|
||||
COPY crank-community.png ./crank-community.png
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 3000
|
||||
@@ -0,0 +1,579 @@
|
||||
/* ── Page header ── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.page-heading {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.page-subheading { font-size: 14px; color: var(--text-muted); }
|
||||
|
||||
.btn-new {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--accent-dim);
|
||||
box-shadow: 0 1px 4px rgba(13,148,136,0.4);
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-new:hover {
|
||||
background: var(--accent-dim);
|
||||
box-shadow: 0 2px 10px rgba(13,148,136,0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Stats ── */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.stat-card { background: var(--bg-surface); padding: 18px 22px; }
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.stat-delta { font-size: 12px; color: var(--text-muted); margin-top: 3px; }
|
||||
.stat-delta.up { color: var(--green); }
|
||||
.stat-delta.down { color: var(--red); }
|
||||
|
||||
/* ── Tabs ── */
|
||||
.tab-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 450;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.tab-item:hover { color: var(--text-secondary); }
|
||||
.tab-item.active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
border-bottom-color: var(--accent-bright);
|
||||
}
|
||||
.tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
min-width: 20px;
|
||||
}
|
||||
.tab-item.active .tab-count {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ── Filter bar ── */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 12px;
|
||||
width: 220px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.search-box:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-ring);
|
||||
}
|
||||
.search-box input {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
.search-box input::placeholder { color: var(--text-muted); }
|
||||
.search-box svg { color: var(--text-muted); flex-shrink: 0; }
|
||||
|
||||
/* ── Dropdown filter chip ── */
|
||||
.filter-dropdown { position: relative; }
|
||||
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-chip:hover { border-color: var(--bg-muted); background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.filter-chip.has-value {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-bright);
|
||||
background: var(--accent-glow);
|
||||
}
|
||||
.filter-chip .chevron {
|
||||
opacity: 0.6;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.filter-chip.open .chevron { transform: rotate(180deg); }
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 150;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
}
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13.5px;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.dropdown-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.dropdown-item.selected { color: var(--accent-bright); }
|
||||
.dropdown-item .check {
|
||||
opacity: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
.dropdown-item.selected .check { opacity: 1; }
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.dropdown-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.dropdown-clear:hover { color: var(--red); background: var(--red-bg); }
|
||||
|
||||
/* ── Filter right side ── */
|
||||
.filter-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.results-count { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
.sort-dropdown { position: relative; }
|
||||
.sort-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sort-chip:hover { border-color: var(--bg-muted); background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.sort-chip .chevron { opacity: 0.6; transition: transform 0.15s; }
|
||||
.sort-chip.open .chevron { transform: rotate(180deg); }
|
||||
|
||||
/* ── Table ── */
|
||||
.table-wrap {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.table-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.table-wrap thead tr {
|
||||
background: var(--bg-canvas);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table-wrap th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.th-sortable { cursor: pointer; }
|
||||
.th-sortable:hover { color: var(--text-secondary); }
|
||||
.th-sortable.sort-active { color: var(--text-secondary); }
|
||||
.sort-arrow { margin-left: 4px; opacity: 0.5; font-size: 11px; }
|
||||
.sort-arrow.active { opacity: 1; color: var(--accent-bright); }
|
||||
|
||||
.table-wrap tbody tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.table-wrap tbody tr:last-child { border-bottom: none; }
|
||||
.table-wrap tbody tr:hover { background: var(--bg-overlay); }
|
||||
.table-wrap td { padding: 14px 16px; vertical-align: middle; }
|
||||
|
||||
.op-name { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.op-display { font-size: 12px; color: var(--text-muted); margin-top: 3px; }
|
||||
|
||||
/* ── Badges ── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border: 1px solid;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.badge-rest { color: var(--blue); background: var(--blue-bg); border-color: var(--blue-border); }
|
||||
.badge-graphql { color: var(--purple); background: var(--purple-bg); border-color: var(--purple-border); }
|
||||
.badge-grpc { color: #14b8a6; background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); }
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-active { color: var(--green); background: var(--green-bg); }
|
||||
.status-active .status-dot { background: var(--green); box-shadow: 0 0 0 2px rgba(63,185,80,0.2); }
|
||||
.status-draft { color: var(--amber); background: var(--amber-bg); }
|
||||
.status-draft .status-dot { background: var(--amber); }
|
||||
.status-error { color: var(--red); background: var(--red-bg); }
|
||||
.status-error .status-dot { background: var(--red); box-shadow: 0 0 0 2px rgba(248,81,73,0.2); }
|
||||
.status-inactive { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.status-inactive .status-dot { background: var(--text-muted); }
|
||||
|
||||
.target-url-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.target-url-cell a { color: inherit; text-decoration: none; }
|
||||
.target-url-cell svg { flex-shrink: 0; opacity: 0.5; }
|
||||
|
||||
.date-cell { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; justify-content: flex-end; }
|
||||
.row-btn {
|
||||
padding: 5px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.row-btn:hover { border-color: var(--bg-muted); color: var(--text-primary); background: var(--bg-overlay); }
|
||||
.row-btn.danger:hover { border-color: var(--red-border); color: var(--red); background: var(--red-bg); }
|
||||
|
||||
.row-btn-edit {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ── Empty state ── */
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty-state h3 { font-size: 16px; color: var(--text-secondary); margin-bottom: 8px; }
|
||||
.empty-state p { font-size: 13.5px; }
|
||||
|
||||
/* ── Table footer / pagination ── */
|
||||
.table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
.table-footer-info { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.pagination { display: flex; align-items: center; gap: 4px; }
|
||||
.page-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.page-btn:hover:not(:disabled) { border-color: var(--bg-muted); color: var(--text-primary); }
|
||||
.page-btn.active { background: var(--accent); color: #fff; border-color: var(--accent-dim); font-weight: 600; }
|
||||
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
/* ── Banner ── */
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 16px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(13,148,136,0.07);
|
||||
border: 1px solid rgba(13,148,136,0.2);
|
||||
color: var(--accent-bright);
|
||||
font-size: 13.5px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.banner-close {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent-bright);
|
||||
font-size: 16px;
|
||||
opacity: 0.6;
|
||||
line-height: 1;
|
||||
}
|
||||
.banner-close:hover { opacity: 1; }
|
||||
|
||||
/* ═══════════════ RESPONSIVE ═══════════════ */
|
||||
|
||||
/* Tablet: ≤ 960px */
|
||||
@media (max-width: 960px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.col-url { display: none; }
|
||||
.results-count { display: none; }
|
||||
}
|
||||
|
||||
/* Narrow / mobile: ≤ 720px */
|
||||
@media (max-width: 720px) {
|
||||
/* --- Скрываем колонку CREATED --- */
|
||||
.col-created { display: none; }
|
||||
|
||||
/* --- Filter bar → grid --- */
|
||||
.filter-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
/* Search во всю ширину */
|
||||
.search-box {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
/* Каждый dropdown занимает ячейку */
|
||||
.filter-dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
.filter-chip {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
/* Sort справа, во всю строку */
|
||||
.filter-right {
|
||||
grid-column: 1 / -1;
|
||||
margin-left: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.sort-chip { white-space: nowrap; }
|
||||
|
||||
/* action column compact */
|
||||
.row-actions { justify-content: center; }
|
||||
|
||||
/* --- Таблица --- */
|
||||
.table-wrap td { padding: 12px 10px; }
|
||||
.table-wrap th { padding: 8px 10px; }
|
||||
}
|
||||
|
||||
/* Mobile: ≤ 480px */
|
||||
@media (max-width: 480px) {
|
||||
.tab-item { padding: 10px 8px; font-size: 13px; gap: 5px; }
|
||||
|
||||
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
||||
.btn-new { width: 100%; justify-content: center; }
|
||||
|
||||
}
|
||||
|
||||
/* ── Active filter chips ── */
|
||||
.active-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
.active-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px 3px 10px;
|
||||
background: rgba(13,148,136,0.12);
|
||||
border: 1px solid rgba(13,148,136,0.3);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.active-filter-chip-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
opacity: 0.7;
|
||||
border-radius: 50%;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.active-filter-chip-remove:hover { opacity: 1; }
|
||||
.active-filter-clear-all {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.active-filter-clear-all:hover { color: var(--text-secondary); }
|
||||
|
||||
/* ── Operation agent badges ── */
|
||||
.op-agent-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.op-agent-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
background: rgba(99,102,241,0.12);
|
||||
border: 1px solid rgba(99,102,241,0.25);
|
||||
border-radius: 3px;
|
||||
color: #818cf8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Row delete button ── */
|
||||
.row-btn-delete {
|
||||
color: var(--text-muted);
|
||||
transition: color 0.1s, background 0.1s;
|
||||
}
|
||||
.row-btn-delete:hover {
|
||||
color: var(--red, #f87171);
|
||||
background: rgba(248,113,113,0.08);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/* ── Navbar ── */
|
||||
.navbar {
|
||||
height: 60px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 40px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 1px 0 var(--border-subtle), 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-right: 36px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-logo-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #0d9488, #0891b2);
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-logo-text {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.4px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
.nav-link {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
font-weight: 450;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
background: none;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.nav-link:hover { background: var(--bg-overlay); color: var(--text-secondary); }
|
||||
.nav-link.active { color: var(--text-primary); font-weight: 550; background: var(--bg-overlay); }
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav-icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
.nav-icon-btn:hover { background: var(--bg-overlay); color: var(--text-secondary); }
|
||||
.nav-divider { width: 1px; height: 20px; background: var(--border); }
|
||||
|
||||
.nav-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #0d9488, #6366f1);
|
||||
color: #fff;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.nav-avatar:hover { border-color: var(--accent); }
|
||||
|
||||
/* ── User dropdown ── */
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
z-index: 200;
|
||||
}
|
||||
.user-dropdown-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.user-dropdown-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.user-dropdown-role {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.user-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 14px;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.user-dropdown-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.user-dropdown-item.danger:hover { background: var(--red-bg); color: var(--red); }
|
||||
.user-dropdown-item svg { flex-shrink: 0; opacity: 0.7; }
|
||||
|
||||
/* ── Page container ── */
|
||||
.page {
|
||||
max-width: 1160px;
|
||||
margin: 0 auto;
|
||||
padding: 36px 40px 60px;
|
||||
}
|
||||
|
||||
/* ── Hamburger button (hidden on desktop) ── */
|
||||
.nav-hamburger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.1s;
|
||||
order: 10; /* after other right items */
|
||||
}
|
||||
.nav-hamburger:hover { background: var(--bg-overlay); }
|
||||
.nav-hamburger span {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 1.5px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 1px;
|
||||
transition: all 0.2s;
|
||||
transform-origin: center;
|
||||
}
|
||||
.nav-hamburger.open span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); }
|
||||
.nav-hamburger.open span:nth-child(2) { opacity: 0; }
|
||||
.nav-hamburger.open span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }
|
||||
|
||||
/* ── Mobile nav dropdown ── */
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
z-index: 99;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 16px 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
.mobile-nav-link {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.mobile-nav-link:hover { background: var(--bg-overlay); color: var(--text-secondary); }
|
||||
.mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
/* ── Responsive breakpoints ── */
|
||||
@media (max-width: 720px) {
|
||||
.navbar { padding: 0 16px; }
|
||||
.nav-links { display: none; }
|
||||
.nav-hamburger { display: flex; }
|
||||
.mobile-nav { display: block; }
|
||||
|
||||
.page { padding: 20px 16px 40px; }
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 50% at 50% -20%, rgba(13,148,136,0.12) 0%, transparent 70%),
|
||||
var(--bg-canvas);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.login-logo-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, #0d9488, #0891b2);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.login-logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.login-heading {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.4px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.login-sub {
|
||||
font-size: 13.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-note {
|
||||
margin-bottom: 24px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 9px;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.18);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--bg-canvas);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.field-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.field-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(13,148,136,0.25);
|
||||
}
|
||||
|
||||
.field-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.field-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.field-link:hover { text-decoration: underline; }
|
||||
|
||||
.field-link.is-disabled,
|
||||
.field-link:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.field-link.is-disabled:hover,
|
||||
.field-link:disabled:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-signin {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: 1px solid var(--accent-dim);
|
||||
border-radius: 7px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
box-shadow: 0 1px 4px rgba(13,148,136,0.4);
|
||||
}
|
||||
|
||||
.btn-signin:hover {
|
||||
background: var(--accent-dim);
|
||||
box-shadow: 0 2px 10px rgba(13,148,136,0.5), 0 0 0 3px rgba(13,148,136,0.25);
|
||||
}
|
||||
|
||||
.btn-signin:active { transform: translateY(1px); }
|
||||
|
||||
.login-error {
|
||||
display: none;
|
||||
background: var(--red-bg);
|
||||
border: 1px solid var(--red-border);
|
||||
border-radius: 7px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--red);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-error.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-footer a { color: var(--accent); text-decoration: none; }
|
||||
.login-footer a:hover { text-decoration: underline; }
|
||||
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
|
||||
.login-divider-line { flex: 1; height: 1px; background: var(--border); }
|
||||
.login-divider-text { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
.btn-sso {
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.btn-sso:hover { background: var(--bg-muted); color: var(--text-primary); }
|
||||
|
||||
.btn-sso:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-sso:disabled:hover {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-inline-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
text-align: center;
|
||||
margin-top: 28px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-footer-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 6px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: not-allowed;
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
.log-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
animation: pulse 1.8s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.live-dot.is-paused {
|
||||
animation-play-state: paused;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(63,185,80,0.6); }
|
||||
50% { opacity: 0.7; box-shadow: 0 0 0 5px rgba(63,185,80,0); }
|
||||
}
|
||||
|
||||
.live-label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
letter-spacing: 0.2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.live-label.is-paused {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toolbar-sep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }
|
||||
|
||||
.time-range-select {
|
||||
background: var(--bg-canvas);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 5px 28px 5px 10px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.time-range-select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-ring);
|
||||
}
|
||||
|
||||
.log-op-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-status.ok { color: var(--green); }
|
||||
.log-status.err { color: var(--red); }
|
||||
.log-status.warn { color: var(--amber); }
|
||||
|
||||
.log-entry-expanded {
|
||||
padding: 10px 20px 16px calc(20px + 152px + 12px + 58px + 12px);
|
||||
background: var(--bg-canvas);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.log-entry-expanded.open { display: block; }
|
||||
|
||||
.log-detail-block {
|
||||
background: #161b22;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #c9d1d9;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-detail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.log-detail-label:first-child { margin-top: 0; }
|
||||
|
||||
.refresh-btn {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-family: 'Inter', sans-serif;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
.avatar-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-canvas);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.avatar-large {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #0d9488, #6366f1);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.avatar-upload-actions { flex: 1; }
|
||||
.avatar-name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 3px; }
|
||||
.avatar-sub { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; }
|
||||
|
||||
.save-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: rgba(13,17,23,0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.save-bar.visible { display: flex; }
|
||||
|
||||
.section-anchor { scroll-margin-top: 80px; }
|
||||
|
||||
.danger-zone-action {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.danger-zone-action:last-child { border-bottom: none; }
|
||||
|
||||
.danger-zone-text { flex: 1; }
|
||||
.danger-zone-title { font-size: 13.5px; font-weight: 500; color: var(--text-primary); margin-bottom: 3px; }
|
||||
.danger-zone-desc { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
.toggle-setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.toggle-setting-row:last-child { border-bottom: none; }
|
||||
|
||||
.toggle-sm {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-muted);
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-sm.on { background: var(--accent); border-color: var(--accent-dim); }
|
||||
|
||||
.toggle-sm::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px; top: 1px;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-sm.on::after { transform: translateX(16px); }
|
||||
|
||||
.toggle-setting-text { flex: 1; }
|
||||
.toggle-setting-label { font-size: 13.5px; font-weight: 500; color: var(--text-primary); }
|
||||
.toggle-setting-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
.member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.member-row:last-child { border-bottom: none; }
|
||||
|
||||
.member-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-muted);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-info { flex: 1; }
|
||||
.member-name { font-size: 13.5px; color: var(--text-primary); font-weight: 500; }
|
||||
.member-email { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.member-role-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.role-admin { background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); color: var(--accent); }
|
||||
.role-member { background: var(--bg-overlay); border-color: var(--border); color: var(--text-muted); }
|
||||
.role-viewer { background: rgba(88,166,255,0.07); border-color: var(--blue-border); color: var(--blue); }
|
||||
|
||||
.planned-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.planned-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.24);
|
||||
background: rgba(245, 158, 11, 0.09);
|
||||
color: var(--amber);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.planned-badge.small {
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
.planned-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.planned-item {
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.planned-item-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.planned-item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.planned-item-desc {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
.period-select {
|
||||
background: var(--bg-canvas);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 28px 7px 11px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.period-select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-ring);
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
background: var(--bg-canvas);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
height: 248px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 12px 16px;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chart-col-bars {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
transition: opacity 0.15s;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chart-bar:hover { opacity: 0.8; }
|
||||
.chart-bar:hover::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chart-bar.success { background: linear-gradient(180deg, var(--accent) 0%, rgba(13,148,136,0.4) 100%); }
|
||||
.chart-bar.error { background: linear-gradient(180deg, var(--red) 0%, rgba(248,81,73,0.3) 100%); }
|
||||
|
||||
.chart-col-label {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 10.5px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-y-grid {
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0; bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chart-y-line {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.quota-bar-wrap {
|
||||
height: 8px;
|
||||
background: var(--bg-muted);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.quota-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, var(--accent), #7c3aed);
|
||||
}
|
||||
|
||||
.latency-bar {
|
||||
display: flex;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.latency-seg { border-radius: 2px; }
|
||||
.latency-p50 { background: var(--accent); }
|
||||
.latency-p95 { background: var(--amber); }
|
||||
.latency-p99 { background: var(--red); }
|
||||
@@ -0,0 +1,64 @@
|
||||
:root {
|
||||
--bg-canvas: #0d1117;
|
||||
--bg-surface: #161b22;
|
||||
--bg-overlay: #21262d;
|
||||
--bg-muted: #30363d;
|
||||
--border: rgba(48, 54, 61, 0.9);
|
||||
--border-subtle: rgba(33, 38, 45, 0.9);
|
||||
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
|
||||
--accent: #0d9488;
|
||||
--accent-dim: #0f766e;
|
||||
--accent-bright: #14b8a6;
|
||||
--accent-glow: rgba(13, 148, 136, 0.15);
|
||||
--accent-ring: rgba(13, 148, 136, 0.25);
|
||||
|
||||
--red: #f85149;
|
||||
--red-bg: rgba(248, 81, 73, 0.10);
|
||||
--red-border: rgba(248, 81, 73, 0.25);
|
||||
--amber: #d29922;
|
||||
--amber-bg: rgba(210, 153, 34, 0.10);
|
||||
--amber-border: rgba(210, 153, 34, 0.25);
|
||||
--green: #3fb950;
|
||||
--green-bg: rgba(63, 185, 80, 0.10);
|
||||
--green-border: rgba(63, 185, 80, 0.25);
|
||||
--blue: #58a6ff;
|
||||
--blue-bg: rgba(88, 166, 255, 0.08);
|
||||
--blue-border: rgba(88, 166, 255, 0.20);
|
||||
--purple: #bc8cff;
|
||||
--purple-bg: rgba(188, 140, 255, 0.08);
|
||||
--purple-border: rgba(188, 140, 255, 0.20);
|
||||
|
||||
--radius-sm: 5px;
|
||||
--radius: 7px;
|
||||
--radius-lg: 10px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
min-width: 360px;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: var(--bg-canvas);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-muted); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #484f58; }
|
||||
::selection { background: var(--accent-glow); color: var(--text-primary); }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
.ws-setup-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-canvas);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ws-setup-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.ws-setup-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ws-setup-logo-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: #0d9488;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ws-setup-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.ws-setup-back:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); }
|
||||
|
||||
.ws-setup-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 24px 80px;
|
||||
}
|
||||
.ws-setup-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
.ws-setup-header { margin-bottom: 32px; }
|
||||
.ws-setup-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ws-setup-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ws-form-section {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ws-form-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/* Avatar */
|
||||
.ws-avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.ws-avatar-preview {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ws-avatar-hint { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
|
||||
.ws-color-swatches { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.ws-color-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
}
|
||||
.ws-color-swatch:hover { transform: scale(1.15); }
|
||||
.ws-color-swatch.active { border-color: var(--text-primary); }
|
||||
|
||||
/* Invite rows */
|
||||
.invite-rows { display: flex; flex-direction: column; gap: 8px; }
|
||||
.invite-row { display: flex; align-items: center; gap: 8px; }
|
||||
.invite-row .form-input { margin-bottom: 0; }
|
||||
.invite-row-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.1s, background 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.invite-row-remove:hover { color: var(--error, #f87171); background: rgba(248,113,113,0.08); }
|
||||
|
||||
/* Members */
|
||||
.member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.member-row:last-child { border-bottom: none; }
|
||||
.member-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-muted);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.member-info { flex: 1; }
|
||||
.member-name { font-size: 13.5px; color: var(--text-primary); font-weight: 500; }
|
||||
.member-email { font-size: 12px; color: var(--text-muted); }
|
||||
.member-last-active { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
/* Danger zone */
|
||||
.danger-zone-action {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.danger-zone-action:last-child { border-bottom: none; }
|
||||
.danger-zone-text { flex: 1; }
|
||||
.danger-zone-title { font-size: 13.5px; font-weight: 500; color: var(--text-primary); margin-bottom: 3px; }
|
||||
.danger-zone-desc { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
/* Lang switcher */
|
||||
.lang-switcher { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.lang-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-canvas);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.lang-btn.active {
|
||||
background: rgba(13,148,136,0.1);
|
||||
border-color: rgba(13,148,136,0.35);
|
||||
color: var(--accent);
|
||||
}
|
||||
.lang-btn:hover:not(.active) { background: rgba(255,255,255,0.04); color: var(--text-primary); }
|
||||
.lang-flag { font-size: 14px; }
|
||||
|
||||
/* Actions row */
|
||||
.ws-setup-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.ws-submit-btn {
|
||||
padding: 9px 22px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ws-setup-footer-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Roles reference */
|
||||
.role-def-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.role-def-row:last-child { border-bottom: none; }
|
||||
.role-def-text { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
|
||||
@@ -0,0 +1,58 @@
|
||||
[
|
||||
{
|
||||
"id": "ag-001",
|
||||
"slug": "customer-support",
|
||||
"display_name": "Customer Support",
|
||||
"description": "Handles ticket creation, status lookups, and customer data retrieval. Powers the support team's Claude integration.",
|
||||
"status": "active",
|
||||
"operation_count": 5,
|
||||
"operation_ids": ["op_01", "op_02", "op_07", "op_09", "op_10"],
|
||||
"key_count": 2,
|
||||
"created_at": "2024-11-15T10:22:00Z",
|
||||
"created_by": "AT",
|
||||
"last_called_at": "2025-03-28T18:44:00Z",
|
||||
"calls_today": 312
|
||||
},
|
||||
{
|
||||
"id": "ag-002",
|
||||
"slug": "sales-pipeline",
|
||||
"display_name": "Sales Pipeline",
|
||||
"description": "CRM operations for the sales team: lead creation, deal updates, contact lookup, and company enrichment.",
|
||||
"status": "active",
|
||||
"operation_count": 5,
|
||||
"operation_ids": ["op_01", "op_06", "op_08", "op_11", "op_12"],
|
||||
"key_count": 1,
|
||||
"created_at": "2024-11-28T14:10:00Z",
|
||||
"created_by": "AT",
|
||||
"last_called_at": "2025-03-29T09:01:00Z",
|
||||
"calls_today": 541
|
||||
},
|
||||
{
|
||||
"id": "ag-003",
|
||||
"slug": "internal-devops",
|
||||
"display_name": "Internal DevOps",
|
||||
"description": "Infrastructure tooling: deployment triggers, service health checks, and incident management workflows.",
|
||||
"status": "draft",
|
||||
"operation_count": 3,
|
||||
"operation_ids": ["op_04", "op_05", "op_11"],
|
||||
"key_count": 0,
|
||||
"created_at": "2025-01-08T09:45:00Z",
|
||||
"created_by": "AT",
|
||||
"last_called_at": null,
|
||||
"calls_today": 0
|
||||
},
|
||||
{
|
||||
"id": "ag-004",
|
||||
"slug": "product-analytics",
|
||||
"display_name": "Product Analytics",
|
||||
"description": "Queries usage metrics, feature adoption data, and user behaviour reports from the analytics stack.",
|
||||
"status": "active",
|
||||
"operation_count": 4,
|
||||
"operation_ids": ["op_02", "op_03", "op_08", "op_12"],
|
||||
"key_count": 3,
|
||||
"created_at": "2025-02-01T11:30:00Z",
|
||||
"created_by": "AT",
|
||||
"last_called_at": "2025-03-29T11:22:00Z",
|
||||
"calls_today": 87
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"acme-workspace": [
|
||||
{ "id": "key_01", "name": "Production", "prefix": "mcp_prod_4f8a…", "scopes": ["read","write","deploy"], "created": "2025-11-15", "lastUsed": "Today", "status": "active" },
|
||||
{ "id": "key_02", "name": "CI / CD pipeline", "prefix": "mcp_ci_b21c…", "scopes": ["read","deploy"], "created": "2025-12-01", "lastUsed": "2 days ago", "status": "active" },
|
||||
{ "id": "key_03", "name": "Dev local", "prefix": "mcp_dev_7e3f…", "scopes": ["read","write"], "created": "2026-01-08", "lastUsed": "Never", "status": "active" },
|
||||
{ "id": "key_04", "name": "Legacy integration", "prefix": "mcp_leg_c90d…", "scopes": ["read"], "created": "2025-09-22", "lastUsed": "45 days ago", "status": "revoked" }
|
||||
],
|
||||
"startup-demo": [
|
||||
{ "id": "key_11", "name": "Backend service", "prefix": "mcp_back_9a1b…", "scopes": ["read","write"], "created": "2026-01-20", "lastUsed": "Today", "status": "active" },
|
||||
{ "id": "key_12", "name": "Analytics pipeline", "prefix": "mcp_anal_3c7d…", "scopes": ["read"], "created": "2026-02-05", "lastUsed": "3 days ago", "status": "active" }
|
||||
],
|
||||
"personal": [
|
||||
{ "id": "key_21", "name": "Local experiments", "prefix": "mcp_loc_f2e8…", "scopes": ["read","write","deploy"], "created": "2026-03-01", "lastUsed": "Yesterday", "status": "active" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
[
|
||||
{
|
||||
"id": "op_01",
|
||||
"name": "create_crm_lead",
|
||||
"display_name": "Create CRM Lead",
|
||||
"protocol": "rest",
|
||||
"method": "POST",
|
||||
"status": "active",
|
||||
"category": "CRM",
|
||||
"target_url": "https://api.acme.com/v2/crm/leads",
|
||||
"created_at": "2026-03-21"
|
||||
},
|
||||
{
|
||||
"id": "op_02",
|
||||
"name": "get_user_profile",
|
||||
"display_name": "Get User Profile",
|
||||
"protocol": "rest",
|
||||
"method": "GET",
|
||||
"status": "active",
|
||||
"category": "Users",
|
||||
"target_url": "https://api.acme.com/v1/users/{id}",
|
||||
"created_at": "2026-03-18"
|
||||
},
|
||||
{
|
||||
"id": "op_03",
|
||||
"name": "search_knowledge_base",
|
||||
"display_name": "Search Knowledge Base",
|
||||
"protocol": "graphql",
|
||||
"method": null,
|
||||
"status": "active",
|
||||
"category": "Search",
|
||||
"target_url": "https://graph.notion-int.acme.com/graphql",
|
||||
"created_at": "2026-03-14"
|
||||
},
|
||||
{
|
||||
"id": "op_04",
|
||||
"name": "send_slack_message",
|
||||
"display_name": "Send Slack Message",
|
||||
"protocol": "rest",
|
||||
"method": "POST",
|
||||
"status": "error",
|
||||
"category": "Notifications",
|
||||
"target_url": "https://slack.com/api/chat.postMessage",
|
||||
"created_at": "2026-03-10"
|
||||
},
|
||||
{
|
||||
"id": "op_05",
|
||||
"name": "stream_telemetry",
|
||||
"display_name": "Stream Telemetry Events",
|
||||
"protocol": "grpc",
|
||||
"method": null,
|
||||
"status": "draft",
|
||||
"category": "Observability",
|
||||
"target_url": "grpc://telemetry.internal.acme.com:9090",
|
||||
"created_at": "2026-03-05"
|
||||
},
|
||||
{
|
||||
"id": "op_06",
|
||||
"name": "update_deal_stage",
|
||||
"display_name": "Update Deal Stage",
|
||||
"protocol": "rest",
|
||||
"method": "PATCH",
|
||||
"status": "inactive",
|
||||
"category": "CRM",
|
||||
"target_url": "https://api.acme.com/v2/crm/deals/{id}",
|
||||
"created_at": "2026-02-28"
|
||||
},
|
||||
{
|
||||
"id": "op_07",
|
||||
"name": "fetch_invoice",
|
||||
"display_name": "Fetch Invoice",
|
||||
"protocol": "rest",
|
||||
"method": "GET",
|
||||
"status": "active",
|
||||
"category": "Billing",
|
||||
"target_url": "https://billing.acme.com/v1/invoices/{id}",
|
||||
"created_at": "2026-02-20"
|
||||
},
|
||||
{
|
||||
"id": "op_08",
|
||||
"name": "list_products",
|
||||
"display_name": "List Products",
|
||||
"protocol": "graphql",
|
||||
"method": null,
|
||||
"status": "active",
|
||||
"category": "Catalog",
|
||||
"target_url": "https://shop.acme.com/graphql",
|
||||
"created_at": "2026-02-15"
|
||||
},
|
||||
{
|
||||
"id": "op_09",
|
||||
"name": "create_support_ticket",
|
||||
"display_name": "Create Support Ticket",
|
||||
"protocol": "rest",
|
||||
"method": "POST",
|
||||
"status": "active",
|
||||
"category": "Support",
|
||||
"target_url": "https://support.acme.com/api/tickets",
|
||||
"created_at": "2026-02-10"
|
||||
},
|
||||
{
|
||||
"id": "op_10",
|
||||
"name": "get_order_status",
|
||||
"display_name": "Get Order Status",
|
||||
"protocol": "rest",
|
||||
"method": "GET",
|
||||
"status": "draft",
|
||||
"category": "Orders",
|
||||
"target_url": "https://orders.acme.com/v2/status/{id}",
|
||||
"created_at": "2026-02-05"
|
||||
},
|
||||
{
|
||||
"id": "op_11",
|
||||
"name": "sync_contacts",
|
||||
"display_name": "Sync Contacts",
|
||||
"protocol": "grpc",
|
||||
"method": null,
|
||||
"status": "active",
|
||||
"category": "CRM",
|
||||
"target_url": "grpc://contacts.internal.acme.com:9091",
|
||||
"created_at": "2026-01-28"
|
||||
},
|
||||
{
|
||||
"id": "op_12",
|
||||
"name": "send_email_campaign",
|
||||
"display_name": "Send Email Campaign",
|
||||
"protocol": "rest",
|
||||
"method": "POST",
|
||||
"status": "active",
|
||||
"category": "Marketing",
|
||||
"target_url": "https://mail.acme.com/api/v3/campaigns/send",
|
||||
"created_at": "2026-01-20"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,394 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Agents</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/catalog.css">
|
||||
<link rel="stylesheet" href="css/wizard.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body x-data="agents">
|
||||
|
||||
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link active" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu" @click.stop>
|
||||
<div class="nav-avatar" @click="openDropdown = openDropdown === 'user' ? null : 'user'" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown" x-show="openDropdown === 'user'" x-transition style="display:none">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role" id="user-ws-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" onclick="window.location.href='/settings'">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" onclick="window.CrankAuth.logout()">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link active" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════ PAGE ═══════════════════ -->
|
||||
<div class="page">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-heading" data-i18n="agents.title">Agents</h1>
|
||||
<p class="page-subheading" x-text="subtitleText()">Named MCP endpoints that expose a curated subset of operations to an LLM</p>
|
||||
</div>
|
||||
<button class="btn-new" @click="openCreate()">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="agents.new">New agent</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="agents.stats.total">Total agents</div>
|
||||
<div class="stat-value" x-text="agents.length">—</div>
|
||||
<div class="stat-delta" x-text="tKey('agents.stats.total_sub')">across this workspace</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="agents.stats.published">Published</div>
|
||||
<div class="stat-value" x-text="activeCount">—</div>
|
||||
<div class="stat-delta" x-text="agents.length ? tfKey('agents.stats.published_sub', { value: Math.round(activeCount/agents.length*100) }) : ''">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="agents.stats.tools">Operations exposed</div>
|
||||
<div class="stat-value" x-text="totalOpsExposed">—</div>
|
||||
<div class="stat-delta" x-text="tfKey('agents.stats.tools_sub', { count: agents.length })">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="agents.stats.calls">Calls today</div>
|
||||
<div class="stat-value" x-text="formatCalls(totalCalls)">—</div>
|
||||
<div class="stat-delta up" x-text="tKey('agents.stats.calls_sub')">all agents combined</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + filter bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="search-box">
|
||||
<svg width="14" height="14"><use href="icons/general/search.svg#icon"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
data-i18n-ph="agents.search"
|
||||
placeholder="Search agents..."
|
||||
:value="agentSearch"
|
||||
@input="agentSearch = $event.target.value"
|
||||
>
|
||||
</div>
|
||||
<div class="filter-right">
|
||||
<span class="results-count" x-show="agentSearch">
|
||||
<span x-text="tfKey('agents.results', { shown: filteredAgents.length, total: agents.length })"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info callout -->
|
||||
<div class="agents-info-callout">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 11V8M8 5v-.5"/></svg>
|
||||
<span x-text="agentCalloutText()">Each agent gets its own MCP endpoint.</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div class="loading-spinner" x-show="loading">
|
||||
<div class="spinner"></div>
|
||||
<span data-i18n="agents.loading">Loading agents…</span>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" x-show="!loading && loadError">
|
||||
<div class="empty-state-title" data-i18n="agents.error.title">Failed to load agents</div>
|
||||
<div class="empty-state-sub" x-text="loadError"></div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: no agents at all -->
|
||||
<div class="empty-state" x-show="!loading && !loadError && agents.length === 0">
|
||||
<div class="empty-state-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.35"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 17.5h7M17.5 14v7"/></svg>
|
||||
</div>
|
||||
<div class="empty-state-title" data-i18n="agents.empty.initial.title">No agents yet</div>
|
||||
<div class="empty-state-sub" x-text="emptyStateText()">Create your first agent to get a dedicated MCP endpoint with a curated set of tools.</div>
|
||||
<div class="empty-state-sub" style="margin-top:8px;" x-text="tKey('agents.empty.initial.next_step')">After creation, issue separate machine-access keys for this endpoint on the API Keys page.</div>
|
||||
<button class="btn-new" @click="openCreate()" style="margin-top: 16px;">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="agents.new">New agent</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: search returned nothing -->
|
||||
<div class="empty-state" x-show="!loading && !loadError && agents.length > 0 && filteredAgents.length === 0">
|
||||
<div class="empty-state-title" x-text="tfKey('agents.empty.search.title', { query: agentSearch })">No agents match</div>
|
||||
<div class="empty-state-sub" data-i18n="agents.empty.search.sub">Try a different search term.</div>
|
||||
</div>
|
||||
|
||||
<!-- Agents grid -->
|
||||
<div class="agents-grid" x-show="!loading && !loadError && filteredAgents.length > 0">
|
||||
<template x-for="agent in filteredAgents" :key="agent.id">
|
||||
<div class="agent-card">
|
||||
|
||||
<!-- Card header -->
|
||||
<div class="agent-card-header">
|
||||
<div class="agent-card-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 17.5h7M17.5 14v7"/></svg>
|
||||
</div>
|
||||
<div class="agent-card-title-wrap">
|
||||
<div class="agent-card-name" x-text="agent.display_name"></div>
|
||||
<div class="agent-card-slug" x-text="agent.slug"></div>
|
||||
</div>
|
||||
<span :class="statusClass(agent.status)" x-text="lifecycleLabel(agent)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="agent-card-desc" x-text="agent.description"></div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="agent-card-stats">
|
||||
<div class="agent-stat">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 5h12M2 8h8M2 11h5"/></svg>
|
||||
<span class="agent-stat-value" x-text="agent.operation_count"></span>
|
||||
<span data-i18n="agents.card.tools">tools</span>
|
||||
</div>
|
||||
<div class="agent-stat">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 2H4a1 1 0 00-1 1v3M7 2l7 7M7 2v5h5"/><path d="M3 9v4a1 1 0 001 1h8a1 1 0 001-1V9"/></svg>
|
||||
<span class="agent-stat-value" x-text="agent.key_count"></span>
|
||||
<span data-i18n="agents.card.keys">API keys</span>
|
||||
</div>
|
||||
<div class="agent-stat" x-show="agent.calls_today > 0">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 8s3-5 7-5 7 5 7 5-3 5-7 5-7-5-7-5z"/><circle cx="8" cy="8" r="2"/></svg>
|
||||
<span class="agent-stat-value" x-text="formatCalls(agent.calls_today)"></span>
|
||||
<span data-i18n="agents.card.calls_today">calls today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP endpoint -->
|
||||
<div class="agent-endpoint-row">
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8h12M10 4l4 4-4 4"/></svg>
|
||||
<code class="agent-endpoint-url" x-text="mcpEndpoint(agent)"></code>
|
||||
<button class="agent-endpoint-copy" @click.stop="copyEndpoint(agent)" :title="tKey('agents.card.copy_endpoint')">
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="9" height="9" rx="1"/><path d="M5 5V3a1 1 0 011-1h7a1 1 0 011 1v7a1 1 0 01-1 1h-2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="agent-card-date" style="margin-top:8px;" x-text="endpointHelpText(agent)"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="agent-card-footer">
|
||||
<span class="agent-card-date" x-text="tfKey('agents.card.created', { date: formatDate(agent.created_at) })"></span>
|
||||
<div class="agent-card-actions">
|
||||
<button class="agent-action-btn" @click.stop="openEdit(agent)">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2l3 3-8 8H3v-3l8-8z"/></svg>
|
||||
<span data-i18n="agents.action.edit">Edit</span>
|
||||
</button>
|
||||
<button class="agent-action-btn" @click.stop="applyLifecycle(agent, lifecycleAction(agent).key)">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 3.5v9l8-4.5-8-4.5z"/></svg>
|
||||
<span x-text="lifecycleAction(agent).label"></span>
|
||||
</button>
|
||||
<button class="agent-action-btn" x-show="agent.raw_status !== 'archived'" @click.stop="applyLifecycle(agent, 'archive')">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4.5h11v2l-1 6.5h-8l-1-6.5v-2z"/><path d="M5 4.5v-1A1.5 1.5 0 016.5 2h3A1.5 1.5 0 0111 3.5v1"/></svg>
|
||||
<span data-i18n="agents.action.archive">Archive</span>
|
||||
</button>
|
||||
<button class="agent-action-btn danger" @click.stop="deleteAgent(agent.id)">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
|
||||
<span data-i18n="agents.action.delete">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div><!-- /page -->
|
||||
|
||||
|
||||
<!-- ═══════════════════ DRAWER ═══════════════════ -->
|
||||
<!-- Overlay -->
|
||||
<div class="drawer-overlay" x-show="drawerOpen" x-transition:enter="transition-opacity" @click="closeDrawer()" style="display:none"></div>
|
||||
|
||||
<!-- Drawer panel -->
|
||||
<div class="drawer" :class="{ open: drawerOpen }" style="display:none" x-show="drawerOpen">
|
||||
|
||||
<!-- Drawer header -->
|
||||
<div class="drawer-header">
|
||||
<div>
|
||||
<div class="drawer-title" x-text="drawerMode === 'create' ? tKey('agents.drawer.new_title') : tKey('agents.drawer.edit_title')"></div>
|
||||
<div class="drawer-subtitle" x-text="drawerSubText()"></div>
|
||||
</div>
|
||||
<button class="drawer-close" @click="closeDrawer()">
|
||||
<svg width="14" height="14" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Drawer body -->
|
||||
<div class="drawer-body">
|
||||
|
||||
<!-- Identity section -->
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-section-title" data-i18n="agents.drawer.identity">Identity</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 14px;">
|
||||
<label class="form-label"><span data-i18n="agents.drawer.display_name">Display name</span> <span class="form-label-required" data-i18n="agents.drawer.required">required</span></label>
|
||||
<input class="form-input" type="text" data-i18n-ph="agents.drawer.placeholder.name" placeholder="Customer Support" autocomplete="off"
|
||||
:value="form.display_name" @input="onNameInput($event.target.value)">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 14px;">
|
||||
<label class="form-label"><span data-i18n="agents.drawer.slug">Slug</span> <span class="form-label-required" data-i18n="agents.drawer.required">required</span></label>
|
||||
<input class="form-input input-mono" type="text" data-i18n-ph="agents.drawer.placeholder.slug" placeholder="customer-support" autocomplete="off"
|
||||
:value="form.slug" @input="onSlugInput($event.target.value)">
|
||||
<div class="form-hint" x-show="form.slug">
|
||||
<span data-i18n="agents.drawer.endpoint">MCP endpoint</span>: <code style="font-family:monospace;font-size:11px;color:var(--accent)" x-text="'/mcp/v1/' + (localStorage.getItem('crank_workspace_slug') || 'default') + '/' + form.slug"></code>
|
||||
</div>
|
||||
<div class="form-hint" data-i18n="agents.drawer.slug_hint">Keep the slug stable after clients start using this endpoint. Changing it changes the MCP path.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 14px;">
|
||||
<label class="form-label"><span data-i18n="agents.drawer.description">Description</span> <span class="form-label-optional" data-i18n="agents.drawer.optional">(optional)</span></label>
|
||||
<textarea class="form-textarea" rows="3" data-i18n-ph="agents.drawer.placeholder.description" placeholder="What does this agent do? What LLM or use-case is it for?"
|
||||
x-model="form.description"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="agents.drawer.status">Status</label>
|
||||
<div class="agent-status-toggle">
|
||||
<button class="agent-status-opt" :class="{ active: form.status === 'published' }" @click="form.status = 'published'">
|
||||
<span class="status-dot" style="background: var(--success, #3fb950)"></span> <span data-i18n="agents.lifecycle.published">Published</span>
|
||||
</button>
|
||||
<button class="agent-status-opt" :class="{ active: form.status === 'draft' }" @click="form.status = 'draft'">
|
||||
<span class="status-dot" style="background: var(--text-muted)"></span> <span data-i18n="agents.lifecycle.draft">Draft</span>
|
||||
</button>
|
||||
<button class="agent-status-opt" :class="{ active: form.status === 'archived' }" @click="form.status = 'archived'">
|
||||
<span class="status-dot" style="background: var(--orange, #d29922)"></span> <span data-i18n="agents.lifecycle.archived">Archived</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operations picker section -->
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-section-header">
|
||||
<div class="drawer-section-title" data-i18n="agents.drawer.operations">Operations</div>
|
||||
<span class="drawer-section-count" x-text="tfKey('agents.drawer.selected', { count: form.selectedOps.length })"></span>
|
||||
</div>
|
||||
<div class="drawer-section-sub" x-text="operationsSubText()">Select which operations this agent exposes. LLMs connecting to this agent will only see these tools.</div>
|
||||
|
||||
<div class="ops-picker">
|
||||
<!-- Search -->
|
||||
<div class="ops-picker-search">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round"><circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/></svg>
|
||||
<input class="ops-picker-search-input" type="text" data-i18n-ph="agents.drawer.filter_ops" placeholder="Filter operations…" x-model="opSearch" autocomplete="off" spellcheck="false">
|
||||
<button class="ops-picker-clear" x-show="opSearch" @click="opSearch = ''" style="display:none">
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- List -->
|
||||
<div class="ops-picker-list">
|
||||
<template x-for="op in filteredOps" :key="op.id || op.name">
|
||||
<div class="ops-picker-item" :class="{ selected: isOpSelected(op.id || op.name) }" @click="toggleOp(op.id || op.name)">
|
||||
<div class="ops-picker-check">
|
||||
<svg x-show="isOpSelected(op.id || op.name)" width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
|
||||
</div>
|
||||
<div class="ops-picker-info">
|
||||
<div class="ops-picker-name" x-text="op.display_name || op.name"></div>
|
||||
<div class="ops-picker-slug" x-text="op.name"></div>
|
||||
</div>
|
||||
<span :class="protocolBadge(op)" x-text="protocolLabel(op)" style="font-size:10px; padding: 2px 6px;"></span>
|
||||
<div class="ops-picker-status-dot" :class="'ops-status-' + op.status"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ops-picker-empty" x-show="filteredOps.length === 0">
|
||||
<span x-text="tfKey('agents.drawer.ops_no_match', { query: opSearch })"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer count -->
|
||||
<div class="ops-picker-footer" x-show="form.selectedOps.length > 0">
|
||||
<span x-text="tfKey('agents.drawer.ops_selected', { count: form.selectedOps.length })"></span>
|
||||
<button @click="form.selectedOps = []" style="background:none;border:none;color:var(--text-muted);font-size:12px;cursor:pointer;margin-left:8px;" data-i18n="agents.drawer.clear_all">Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommendation callout -->
|
||||
<div class="agents-rec-callout" x-show="form.selectedOps.length > 15">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polygon points="8,1 15,14 1,14" fill="none"/><path d="M8 6v4M8 11.5v.5"/></svg>
|
||||
<span x-text="tfKey('agents.drawer.recommendation', { count: form.selectedOps.length })">You've selected tools.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /drawer-body -->
|
||||
|
||||
<!-- Drawer footer -->
|
||||
<div class="drawer-footer">
|
||||
<button class="btn-ghost-sm" style="padding: 8px 16px; font-size: 13px;" @click="closeDrawer()" data-i18n="agents.drawer.cancel">Cancel</button>
|
||||
<button class="btn-primary-sm" style="padding: 8px 20px; font-size: 13px;"
|
||||
:disabled="!form.display_name.trim() || !form.slug.trim()"
|
||||
@click="saveAgent()"
|
||||
x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')">
|
||||
Create agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div><!-- /drawer -->
|
||||
|
||||
|
||||
<script src="%CRANK_BUNDLE_AGENTS%"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,319 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Agent Keys</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<style>
|
||||
.scope-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
.scope-checkbox-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.scope-checkbox-desc { font-size: 11.5px; color: var(--text-muted); }
|
||||
.keys-card-list { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
#keys-table-wrap { display: none; }
|
||||
.keys-card-list { display: grid; }
|
||||
}
|
||||
</style>
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link active" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
|
||||
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" data-action="settings-link">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link active" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════ PAGE ═══════════════════ -->
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="apikeys.title">Agent Keys</h1>
|
||||
<p class="page-subtitle" data-i18n="apikeys.subtitle">Keys authenticate external MCP clients against a specific AI agent endpoint.</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-primary" id="btn-create-key" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||
<span data-i18n="apikeys.new">Create key</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout info">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong data-i18n="apikeys.callout.title">Keys are only shown once.</strong>
|
||||
<span data-i18n="apikeys.callout.body">Copy and store them securely immediately after creation — Crank stores only a hash. `Last used` updates after successful MCP authentication.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="apikeys.agent.title">Agent access</div>
|
||||
<div class="section-card-subtitle" id="agent-access-subtitle" data-i18n="apikeys.agent.subtitle">Select the AI agent whose MCP endpoint should accept this key.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="display:grid;gap:12px;">
|
||||
<div class="field-group" style="margin-bottom:0;">
|
||||
<label class="field-label" for="agent-select" data-i18n="apikeys.agent.label">AI agent</label>
|
||||
<select class="field-input" id="agent-select"></select>
|
||||
<div class="field-hint" id="agent-endpoint-hint" data-testid="agent-key-endpoint-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" id="machine-access-title" data-i18n="apikeys.machine_access.title">Machine access modes</div>
|
||||
<div class="section-card-subtitle" id="machine-access-subtitle" data-i18n="apikeys.machine_access.subtitle">This build exposes the stable machine-access contract and shows which modes are available now.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="display:grid;gap:10px;">
|
||||
<div style="font-size:13px;color:var(--text-secondary);line-height:1.65;" id="machine-access-summary" data-testid="machine-access-summary">
|
||||
Community currently supports static AI-agent keys.
|
||||
</div>
|
||||
<div class="callout info">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div id="machine-access-note" data-i18n="apikeys.machine_access.note">Short-lived and one-time token issuance uses the same public HTTP surface, but requires a commercial edition.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="apikeys.active.title">Active keys</div>
|
||||
<div class="section-card-subtitle" id="keys-summary-subtitle">3 active · 1 revoked</div>
|
||||
</div>
|
||||
<div class="filter-bar-search" style="max-width: 220px; margin: 0;">
|
||||
<svg width="13" height="13" style="position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
|
||||
<input type="text" id="key-search" data-i18n-ph="apikeys.search" placeholder="Filter keys…" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="keys-table-wrap">
|
||||
<table class="data-table" id="keys-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="apikeys.th.name">Name</th>
|
||||
<th data-i18n="apikeys.th.prefix">Key prefix</th>
|
||||
<th data-i18n="apikeys.th.scopes">Scopes</th>
|
||||
<th data-i18n="apikeys.th.created">Created</th>
|
||||
<th data-i18n="apikeys.th.last">Last used</th>
|
||||
<th data-i18n="apikeys.th.status">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="keys-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="resource-list keys-card-list" id="keys-card-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="apikeys.scope_ref">Scope reference</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
|
||||
<div>
|
||||
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;display:flex;align-items:center;gap:6px;">
|
||||
<span class="badge badge-scope">read</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.read">Initialize MCP sessions, ping the server, and list tools for a workspace agent.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
|
||||
<span class="badge badge-scope">write</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.write">Execute `tools/call` requests against published agent toolsets.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
|
||||
<span class="badge badge-scope">deploy</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.deploy">Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /page -->
|
||||
|
||||
<!-- ══ Create Key Modal ══ -->
|
||||
<div class="modal-overlay" id="modal-create">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" data-i18n="apikeys.modal.title">Create agent key</span>
|
||||
<button class="modal-close" id="modal-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-form-body">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="apikeys.modal.name">Key name</label>
|
||||
<input class="field-input" id="new-key-name" type="text" data-i18n-ph="apikeys.modal.name_placeholder" placeholder="e.g. Production, CI pipeline" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="apikeys.modal.name_hint">A descriptive label to identify the key. Only visible to admins.</div>
|
||||
</div>
|
||||
<div class="field-group" style="margin-bottom:0;">
|
||||
<label class="field-label" data-i18n="apikeys.modal.scopes">Scopes</label>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;margin-top:2px;" id="scope-checkboxes">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-reveal-body" hidden>
|
||||
<div class="callout warning" style="margin-bottom:16px;">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
|
||||
</svg>
|
||||
<div><strong data-i18n="apikeys.modal.reveal_title">Copy this key now.</strong> <span data-i18n="apikeys.modal.reveal_body">It won't be shown again.</span></div>
|
||||
</div>
|
||||
<div class="key-reveal">
|
||||
<span class="key-mask" id="reveal-key-value">mcp_prod_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</span>
|
||||
<button class="key-copy" id="copy-key-btn" type="button" data-i18n-title="apikeys.modal.copy" title="Copy to clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5"/>
|
||||
<path d="M11 5V3.5A1.5 1.5 0 009.5 2H3.5A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" id="modal-footer-create">
|
||||
<button class="btn-secondary" id="modal-cancel-btn" type="button" data-i18n="apikeys.modal.cancel">Cancel</button>
|
||||
<button class="btn-primary" id="modal-confirm-btn" type="button" data-i18n="apikeys.modal.create">Create key</button>
|
||||
</div>
|
||||
<div class="modal-footer" id="modal-footer-done" hidden>
|
||||
<button class="btn-primary" id="modal-done-btn" type="button" data-i18n="apikeys.modal.done">Done — I've copied the key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template: scope checkbox row -->
|
||||
<template id="tmpl-scope-checkbox">
|
||||
<label class="scope-checkbox-label">
|
||||
<input type="checkbox" data-scope="" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;flex-shrink:0;">
|
||||
<div>
|
||||
<div class="scope-checkbox-name"></div>
|
||||
<div class="scope-checkbox-desc"></div>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- Template: API key table row -->
|
||||
<template id="tmpl-key-row">
|
||||
<tr>
|
||||
<td class="col-name"></td>
|
||||
<td class="col-mono"></td>
|
||||
<td><div class="col-scopes" style="display:flex;gap:4px;flex-wrap:wrap;"></div></td>
|
||||
<td class="col-created"></td>
|
||||
<td class="col-last-used"></td>
|
||||
<td class="col-status"></td>
|
||||
<td class="col-actions">
|
||||
<span class="actions-active">
|
||||
<button class="btn-icon" data-i18n-title="apikeys.action.copy_prefix" title="Copy key prefix"><svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M11 5V3.5A1.5 1.5 0 009.5 2H3.5A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5"/></svg></button>
|
||||
<button class="btn-icon danger" data-i18n-title="apikeys.action.revoke" title="Revoke key"><svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.5"/><path d="M5.5 10.5l5-5M10.5 10.5l-5-5"/></svg></button>
|
||||
</span>
|
||||
<span class="actions-revoked" hidden>
|
||||
<button class="btn-icon" data-i18n-title="apikeys.action.delete" title="Delete"><svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h4M2.5 4h11M5 4l.5 9h5l.5-9"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script src="%CRANK_BUNDLE_API_KEYS%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="field-group" style="margin-top:8px;">
|
||||
<label class="field-label">Interface language</label>
|
||||
<div class="lang-switcher" style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="lang-btn" data-lang="en" onclick="setLang('en')">
|
||||
<span class="lang-flag">🇬🇧</span>
|
||||
<span data-i18n="settings.lang.en">English</span>
|
||||
</button>
|
||||
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">
|
||||
<span class="lang-flag">🇷🇺</span>
|
||||
<span data-i18n="settings.lang.ru">Русский</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Sign in</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/login.css">
|
||||
<script src="%CRANK_BUNDLE_LOGIN%"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
|
||||
<div class="login-logo">
|
||||
<div class="login-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="22" height="22" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="login-logo-text">Crank</span>
|
||||
</div>
|
||||
|
||||
<div class="login-box">
|
||||
<div class="login-heading" data-i18n="login.title">Sign in</div>
|
||||
<div class="login-sub" data-i18n="login.subtitle">Welcome back to your workspace</div>
|
||||
<div class="login-note" data-i18n="login.coming_soon">Password reset and SSO will be added later.</div>
|
||||
|
||||
<div class="login-error" id="login-error" data-i18n="login.error.invalid">
|
||||
Invalid email or password. Please try again.
|
||||
</div>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
<label class="field-label" for="email" data-i18n="login.email_label">Email address</label>
|
||||
<input class="field-input" type="email" id="email" data-i18n-ph="login.email_placeholder" placeholder="you@acme.com" autocomplete="email" autofocus>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="password" data-i18n="login.password">Password</label>
|
||||
<input class="field-input" type="password" id="password" placeholder="••••••••" autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button class="btn-signin" type="submit" data-i18n="login.submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="version-badge">Crank v0.9.0-beta</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Logs</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/logs.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link active" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" data-action="settings-link">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link active" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="logs.title">Logs</h1>
|
||||
<p class="page-subtitle" data-i18n="logs.subtitle">Real-time invocation log for all operations in this workspace.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="log-toolbar">
|
||||
<div class="live-dot"></div>
|
||||
<span class="live-label" data-i18n="logs.live">Live</span>
|
||||
<div class="toolbar-sep"></div>
|
||||
|
||||
<select class="time-range-select" id="time-range">
|
||||
<option value="30m" data-i18n="logs.range.30m">Last 30 min</option>
|
||||
<option value="1h" selected data-i18n="logs.range.1h">Last 1 hour</option>
|
||||
<option value="6h" data-i18n="logs.range.6h">Last 6 hours</option>
|
||||
<option value="24h" data-i18n="logs.range.24h">Last 24 hours</option>
|
||||
<option value="7d" data-i18n="logs.range.7d">Last 7 days</option>
|
||||
</select>
|
||||
|
||||
<div class="toolbar-sep"></div>
|
||||
|
||||
<button class="filter-chip active" data-level="all" id="chip-all" data-i18n="logs.level.all">All</button>
|
||||
<button class="filter-chip" data-level="info" id="chip-info">
|
||||
<span class="log-level info" style="padding:0 4px;font-size:10px;">INFO</span>
|
||||
</button>
|
||||
<button class="filter-chip" data-level="warn" id="chip-warn">
|
||||
<span class="log-level warn" style="padding:0 4px;font-size:10px;">WARN</span>
|
||||
</button>
|
||||
<button class="filter-chip" data-level="error" id="chip-error">
|
||||
<span class="log-level error" style="padding:0 4px;font-size:10px;">ERROR</span>
|
||||
</button>
|
||||
|
||||
<div class="filter-bar-search" style="max-width:240px;margin:0;">
|
||||
<svg width="13" height="13" style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
|
||||
<input type="text" id="log-search" data-i18n-ph="logs.search_messages" placeholder="Search messages…" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<button class="refresh-btn" id="refresh-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 01.834.656 5.5 5.5 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834zM8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 01-1.49.178A5.501 5.501 0 008 2.5z"/></svg>
|
||||
<span data-i18n="logs.refresh">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="log-list" id="log-list">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="%CRANK_BUNDLE_LOGS%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Secrets</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<style>
|
||||
.secret-value-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.secret-inline-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.secret-ref-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.secret-ref-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-canvas);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.secret-ref-pill strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.secrets-card-list { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
#secrets-table-wrap { display: none; }
|
||||
.secrets-card-list { display: grid; }
|
||||
}
|
||||
</style>
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name" data-testid="shell-workspace-name">workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link active" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications"><svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg></button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-testid="shell-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header"><div class="user-dropdown-name" data-testid="shell-user-name">Crank</div><div class="user-dropdown-role" data-testid="shell-user-role">—</div></div>
|
||||
<button class="user-dropdown-item" data-action="settings-link"><svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg><span data-i18n="nav.settings">Settings</span></button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout"><svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg><span data-i18n="nav.logout">Log out</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link active" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="secrets.title">Secrets</h1>
|
||||
<p class="page-subtitle" data-i18n="secrets.subtitle">Encrypted upstream credentials for bearer tokens, basic auth, and API keys used by auth profiles.</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-primary" id="btn-create-secret" data-testid="secret-create-button" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||
<span data-i18n="secrets.new">Create secret</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout info">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong data-i18n="secrets.callout.title">Secret plaintext is write-only.</strong>
|
||||
<span data-i18n="secrets.callout.body">Crank encrypts secret values with the workspace master key. After create or rotate, the API returns only metadata, so this page focuses on lifecycle and usage references.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="margin-top: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="secrets.active.title">Workspace secrets</div>
|
||||
<div class="section-card-subtitle" id="secrets-summary"></div>
|
||||
</div>
|
||||
<div class="filter-bar-search" style="max-width: 240px; margin: 0;">
|
||||
<svg width="13" height="13" style="position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
|
||||
<input type="text" id="secret-search" data-i18n-ph="secrets.search" placeholder="Filter secrets…" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="padding-top: 0;">
|
||||
<div id="secrets-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="secrets.th.name">Name</th>
|
||||
<th data-i18n="secrets.th.kind">Kind</th>
|
||||
<th data-i18n="secrets.th.version">Version</th>
|
||||
<th data-i18n="secrets.th.last_used">Last used</th>
|
||||
<th data-i18n="secrets.th.used_by">Used by</th>
|
||||
<th data-i18n="secrets.th.status">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="secrets-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="resource-list secrets-card-list" id="secrets-card-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="margin-top: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="secrets.profiles.title">Auth profile references</div>
|
||||
<div class="section-card-subtitle" id="secret-profiles-summary" data-i18n="secrets.profiles.subtitle">Profiles resolve secrets at runtime before upstream execution.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<div class="resource-list" id="auth-profiles-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="secret-modal" data-testid="secret-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="secret-modal-title" data-i18n="secrets.modal.create_title">Create secret</span>
|
||||
<button class="modal-close" id="secret-modal-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.name">Secret name</label>
|
||||
<input class="field-input" id="secret-name" data-testid="secret-name-input" type="text" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="secrets.modal.name_hint">Use a stable operator-facing label. Plaintext values are never returned after storage.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.kind">Secret kind</label>
|
||||
<select class="field-select" id="secret-kind" data-testid="secret-kind-select">
|
||||
<option value="token" data-i18n="secrets.kind.token">Token</option>
|
||||
<option value="username_password" data-i18n="secrets.kind.username_password">Username/password</option>
|
||||
<option value="header" data-i18n="secrets.kind.header">Header value</option>
|
||||
<option value="generic" data-i18n="secrets.kind.generic">Generic JSON</option>
|
||||
</select>
|
||||
<div class="field-hint" id="secret-kind-hint"></div>
|
||||
</div>
|
||||
|
||||
<div class="secret-value-grid" id="secret-value-fields">
|
||||
<div class="field-group" data-kind-field="string">
|
||||
<label class="field-label" id="secret-string-label" data-i18n="secrets.modal.value">Secret value</label>
|
||||
<input class="field-input" id="secret-string-value" data-testid="secret-value-input" type="text" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field-row" data-kind-field="basic" hidden>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.username">Username</label>
|
||||
<input class="field-input" id="secret-username" data-testid="secret-username-input" type="text" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.password">Password</label>
|
||||
<input class="field-input" id="secret-password" data-testid="secret-password-input" type="password" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group" data-kind-field="json" hidden>
|
||||
<label class="field-label" data-i18n="secrets.modal.json_payload">JSON payload</label>
|
||||
<textarea class="field-textarea code-textarea" id="secret-json-value" data-testid="secret-json-input" rows="8" spellcheck="false"></textarea>
|
||||
<div class="field-hint" data-i18n="secrets.modal.json_hint">Useful for generic secret payloads. The value must be valid JSON.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-inline-note" id="secret-modal-note" data-i18n="secrets.modal.create_note">Creation stores encrypted plaintext and returns metadata only.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="secret-modal-cancel-btn" type="button" data-i18n="secrets.modal.cancel">Cancel</button>
|
||||
<button class="btn-primary" id="secret-modal-submit-btn" data-testid="secret-submit-button" type="button" data-i18n="secrets.modal.create_action">Create secret</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="%CRANK_BUNDLE_SECRETS%"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,330 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Settings</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/settings.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" data-action="settings-link">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header" style="margin-bottom: 24px;">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="settings.page.title">Account settings</h1>
|
||||
<p class="page-subtitle" data-i18n="settings.page.subtitle">Manage the live profile, password and workspace settings available in this build. Planned capabilities stay visible, but they do not pretend to be wired yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-layout">
|
||||
|
||||
<!-- ── Sidebar nav ── -->
|
||||
<nav class="settings-nav" id="settings-nav">
|
||||
<button class="settings-nav-item" data-section="workspace">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 5.5h12M4 2.5h8a1.5 1.5 0 011.5 1.5v8A1.5 1.5 0 0112 13.5H4A1.5 1.5 0 012.5 12V4A1.5 1.5 0 014 2.5z"/>
|
||||
</svg>
|
||||
<span data-i18n="settings.nav.workspace">Workspace</span>
|
||||
</button>
|
||||
<button class="settings-nav-item active" data-section="profile">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="5" r="3"/>
|
||||
<path d="M1.5 14.5c0-3.314 2.91-6 6.5-6s6.5 2.686 6.5 6"/>
|
||||
</svg>
|
||||
<span data-i18n="settings.nav.profile">Profile</span>
|
||||
</button>
|
||||
<button class="settings-nav-item" data-section="security">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 1L2 3.5v4.5C2 11.3 4.7 14.4 8 15c3.3-.6 6-3.7 6-7V3.5L8 1z"/>
|
||||
</svg>
|
||||
<span data-i18n="settings.nav.security">Security</span>
|
||||
</button>
|
||||
<button class="settings-nav-item" data-section="notifications">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 1.5a5 5 0 015 5v2.5l1 2H2l1-2V6.5a5 5 0 015-5z"/>
|
||||
<path d="M6.5 13a1.5 1.5 0 003 0"/>
|
||||
</svg>
|
||||
<span data-i18n="settings.nav.notif">Notifications</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- ── Content ── -->
|
||||
<div id="settings-content">
|
||||
|
||||
<div id="section-workspace" class="section-anchor">
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="settings.ws.title">Workspace settings</div>
|
||||
<div class="section-card-subtitle" data-i18n="settings.ws.subtitle">General information about your workspace</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="padding-bottom:8px;">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.ws.name">Workspace name</label>
|
||||
<input class="field-input" id="settings-ws-slug" type="text" value="acme-workspace" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="settings.ws.slug_hint">Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.ws.display">Display name</label>
|
||||
<input class="field-input" id="settings-ws-display-name" type="text" value="Acme Corp" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label"><span data-i18n="settings.ws.description_optional">Description (optional)</span></label>
|
||||
<textarea class="field-textarea" id="settings-ws-description" rows="2" data-i18n-ph="settings.ws.description_placeholder" placeholder="What does this workspace do?">Primary production workspace for CRM and e-commerce API operations.</textarea>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.ws.tz">Timezone</label>
|
||||
<select class="field-select" id="settings-ws-timezone">
|
||||
<option selected>UTC</option>
|
||||
<option>America/New_York</option>
|
||||
<option>America/Los_Angeles</option>
|
||||
<option>Europe/London</option>
|
||||
<option>Europe/Paris</option>
|
||||
<option>Asia/Tokyo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.ws.protocol">Default protocol</label>
|
||||
<select class="field-select" id="settings-ws-protocol">
|
||||
<option value="rest" selected>REST / HTTP</option>
|
||||
</select>
|
||||
<div class="field-hint" id="settings-ws-protocol-hint" data-i18n="settings.ws.protocol_hint">This build limits the default protocol to the capabilities available in the current edition.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Language -->
|
||||
<div class="field-group" style="margin-top:4px;">
|
||||
<label class="field-label">
|
||||
<span data-i18n="settings.lang.title">Language</span>
|
||||
</label>
|
||||
<div class="lang-switcher">
|
||||
<button class="lang-btn active" data-lang="en" onclick="setLang('en')">
|
||||
<span class="lang-flag">🇬🇧</span>
|
||||
<span data-i18n="settings.lang.en">English</span>
|
||||
</button>
|
||||
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">
|
||||
<span class="lang-flag">🇷🇺</span>
|
||||
<span data-i18n="settings.lang.ru">Русский</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="field-hint" data-i18n="settings.lang.subtitle">Interface display language</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;padding-top:4px;">
|
||||
<button class="btn-primary" id="settings-ws-save-btn" type="button"><span data-i18n="btn.save">Save changes</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ■ PROFILE ■ -->
|
||||
<div id="section-profile" class="section-anchor">
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="settings.profile.title">Profile</div>
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<div class="avatar-upload">
|
||||
<div class="avatar-large" id="profile-avatar">AT</div>
|
||||
<div class="avatar-upload-actions">
|
||||
<div class="avatar-name" id="profile-display-name">Crank</div>
|
||||
<div class="avatar-sub" id="profile-email">operator@acme-workspace</div>
|
||||
<div class="field-hint" data-i18n="settings.profile.avatar_hint">Your avatar is generated from your display name and email.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-hint" style="margin-bottom:16px;" data-i18n="settings.profile.backend_hint">Your name and email are stored on the backend and used across the console and session identity.</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.profile.first_name">First name</label>
|
||||
<input class="field-input" id="field-firstname" type="text" value="Crank" autocomplete="given-name">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.profile.last_name">Last name</label>
|
||||
<input class="field-input" id="field-lastname" type="text" value="" autocomplete="family-name" data-i18n-ph="settings.profile.last_name_placeholder" placeholder="Optional">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.profile.email_label">Email address</label>
|
||||
<input class="field-input" id="field-email" type="email" value="operator@acme.com" autocomplete="email">
|
||||
<div class="field-hint" data-i18n="settings.profile.email_hint">Changing email updates the login identity for the current account.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-hint" id="settings-profile-status" style="margin-bottom:12px;"></div>
|
||||
<div style="display:flex;justify-content:flex-end;">
|
||||
<button class="btn-primary" id="settings-profile-save-btn" type="button" data-i18n="settings.profile.save">Save profile</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ■ SECURITY ■ -->
|
||||
<div id="section-security" class="section-anchor">
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="settings.security.title">Security</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="padding-bottom:8px;">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.security.current_password">Current password</label>
|
||||
<input class="field-input" id="security-current-password" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.security.new_password">New password</label>
|
||||
<input class="field-input" id="security-new-password" type="password" data-i18n-ph="settings.security.new_password_placeholder" placeholder="min. 12 characters" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.security.confirm_password">Confirm new password</label>
|
||||
<input class="field-input" id="security-confirm-password" type="password" placeholder="••••••••" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-hint" id="settings-password-status" style="margin-bottom:12px;"></div>
|
||||
<div style="display:flex;justify-content:flex-end;padding-bottom:8px;">
|
||||
<button class="btn-primary" id="settings-password-save-btn" type="button" data-i18n="settings.security.change_password">Change password</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 4px 20px 16px;">
|
||||
<div class="settings-section-divider" style="margin-top:0;"></div>
|
||||
<div class="planned-section-title">
|
||||
<span data-i18n="settings.security.capabilities_title">More security options</span>
|
||||
</div>
|
||||
<div class="callout warning" style="margin-bottom:0;">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong id="settings-security-capability-title" data-i18n="settings.security.not_available">Available later.</strong> <span id="settings-security-capability-body" data-i18n="settings.security.capabilities_body">Two-factor authentication, passkeys, and multi-session controls will be added later. The current live flow uses password sign-in with one HttpOnly browser session.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="settings.sso_panel"></div>
|
||||
<div data-slot="settings.totp_panel"></div>
|
||||
<div data-slot="settings.audit_panel"></div>
|
||||
<div data-slot="settings.billing_panel"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="settings.session.title">Current session</div>
|
||||
</div>
|
||||
<div style="padding: 8px 20px 16px;">
|
||||
<div class="member-row">
|
||||
<div class="member-info">
|
||||
<div class="member-name"><span data-i18n="settings.session.browser">Browser session</span> — <span style="color:var(--green);font-size:12px;" data-i18n="settings.session.current">current</span></div>
|
||||
<div class="member-email" id="settings-session-summary" data-i18n="settings.session.loading">Loading current session details…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ■ NOTIFICATIONS ■ -->
|
||||
<div id="section-notifications" class="section-anchor">
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="settings.notifications.title">Notifications</div>
|
||||
</div>
|
||||
<div style="padding: 8px 20px 16px;">
|
||||
<div class="callout info" style="margin-bottom:14px;">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong id="settings-notifications-capability-title" data-i18n="settings.notifications.not_ready_title">Not included in this build.</strong> <span id="settings-notifications-capability-body" data-i18n="settings.notifications.not_ready_body">Notification routing and personal preferences are outside the current Community surface.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /settings-content -->
|
||||
</div><!-- /settings-layout -->
|
||||
|
||||
</div><!-- /page -->
|
||||
|
||||
<script src="%CRANK_BUNDLE_SETTINGS%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Usage</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/usage.css">
|
||||
<style>
|
||||
.usage-card-list { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
#usage-table-wrap { display: none; }
|
||||
.usage-card-list { display: grid; }
|
||||
}
|
||||
</style>
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link active" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" data-action="settings-link">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link active" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="usage.title">Usage</h1>
|
||||
<p class="page-subtitle" data-i18n="usage.subtitle">Invocation metrics for all operations in your workspace.</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<select class="period-select" id="period">
|
||||
<option value="7d" selected data-i18n="usage.period.7d">Last 7 days</option>
|
||||
<option value="30d" data-i18n="usage.period.30d">Last 30 days</option>
|
||||
<option value="90d" data-i18n="usage.period.90d">Last 90 days</option>
|
||||
<option value="this_month" data-i18n="usage.period.this_month">This month</option>
|
||||
</select>
|
||||
<button class="btn-secondary" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.47 1.72a.75.75 0 011.06 0l4.5 4.5a.75.75 0 01-1.06 1.06L8.75 4.06v8.94a.75.75 0 01-1.5 0V4.06L4.53 7.28a.75.75 0 01-1.06-1.06l4.5-4.5zM1.5 13.75a.75.75 0 01.75-.75h11.5a.75.75 0 010 1.5H2.25a.75.75 0 01-.75-.75z"/></svg>
|
||||
<span data-i18n="usage.export">Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="usage.stats.total">Total invocations</div>
|
||||
<div class="stat-value">28,341</div>
|
||||
<div class="stat-delta up">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
|
||||
+12% vs prior period
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="usage.stats.success">Success rate</div>
|
||||
<div class="stat-value">98.6%</div>
|
||||
<div class="stat-delta up">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
|
||||
+0.3pp vs prior period
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="usage.stats.p50">Median latency (p50)</div>
|
||||
<div class="stat-value">124ms</div>
|
||||
<div class="stat-delta up">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
|
||||
-18ms vs prior period
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="usage.stats.p99">p99 latency</div>
|
||||
<div class="stat-value">2.1s</div>
|
||||
<div class="stat-delta down">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 10L2 6h3V2h2v4h3l-4 4z"/></svg>
|
||||
+340ms vs prior period
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invocations chart -->
|
||||
<div class="section-card" style="margin-bottom:20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="usage.chart.title">Invocations over time</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;font-size:12px;color:var(--text-muted);">
|
||||
<span style="display:flex;align-items:center;gap:5px;"><span style="width:10px;height:10px;border-radius:2px;background:var(--accent);display:inline-block;"></span><span data-i18n="usage.chart.success">Success</span></span>
|
||||
<span style="display:flex;align-items:center;gap:5px;"><span style="width:10px;height:10px;border-radius:2px;background:var(--red);display:inline-block;"></span><span data-i18n="usage.chart.error">Error</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="padding-bottom:28px;">
|
||||
<div class="chart-placeholder" id="chart-bars"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-operation table -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="usage.table.title">By operation</div>
|
||||
<div class="section-card-subtitle">Breakdown for last 7 days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="usage-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="usage.table.th.operation">Operation</th>
|
||||
<th data-i18n="usage.table.th.protocol">Protocol</th>
|
||||
<th style="text-align:right;" data-i18n="usage.table.th.calls">Calls</th>
|
||||
<th style="text-align:right;" data-i18n="usage.table.th.errors">Errors</th>
|
||||
<th style="text-align:right;" data-i18n="usage.table.th.error_rate">Error rate</th>
|
||||
<th data-i18n="usage.table.th.latency">Latency (p50 / p95 / p99)</th>
|
||||
<th style="text-align:right;" data-i18n="usage.table.th.share">Traffic share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usage-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="resource-list usage-card-list" id="usage-card-list"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Template: chart bar column -->
|
||||
<template id="tmpl-chart-bar">
|
||||
<div class="chart-col">
|
||||
<div class="chart-col-bars">
|
||||
<div class="chart-bar error"></div>
|
||||
<div class="chart-bar success"></div>
|
||||
</div>
|
||||
<div class="chart-col-label"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Template: usage stats table row -->
|
||||
<template id="tmpl-usage-row">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="col-name"></div>
|
||||
<div class="col-op-slug" style="font-family:monospace;font-size:11px;color:var(--text-muted);"></div>
|
||||
</td>
|
||||
<td><span class="badge col-proto" style="border-color:rgba(88,166,255,0.25);background:rgba(88,166,255,0.07);color:var(--blue);"></span></td>
|
||||
<td class="col-calls" style="text-align:right;font-variant-numeric:tabular-nums;"></td>
|
||||
<td class="col-errors" style="text-align:right;font-variant-numeric:tabular-nums;"></td>
|
||||
<td class="col-err-rate" style="text-align:right;font-variant-numeric:tabular-nums;"></td>
|
||||
<td class="col-latency">
|
||||
<div class="latency-bar">
|
||||
<div class="latency-seg latency-p50"></div>
|
||||
<div class="latency-seg latency-p95"></div>
|
||||
<div class="latency-seg latency-p99"></div>
|
||||
</div>
|
||||
<div class="col-latency-text" style="font-size:11px;color:var(--text-muted);margin-top:3px;font-family:monospace;"></div>
|
||||
</td>
|
||||
<td style="text-align:right;min-width:120px;">
|
||||
<div class="col-quota-label" style="font-size:11.5px;color:var(--text-muted);margin-bottom:3px;"></div>
|
||||
<div class="quota-bar-wrap"><div class="quota-bar-fill col-quota-fill"></div></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script src="%CRANK_BUNDLE_USAGE%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,267 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — New Operation</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../../css/variables.css">
|
||||
<link rel="stylesheet" href="../../css/layout.css">
|
||||
<link rel="stylesheet" href="../../css/wizard.css">
|
||||
<link rel="stylesheet" href="../../css/pages.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body class="wizard-page">
|
||||
|
||||
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name" data-testid="shell-workspace-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link active" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
|
||||
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="../../icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-testid="shell-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name" data-testid="shell-user-name">Crank</div>
|
||||
<div class="user-dropdown-role" data-testid="shell-user-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" data-action="settings-link">
|
||||
<svg width="13" height="13"><use href="../../icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout">
|
||||
<svg width="13" height="13"><use href="../../icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link active" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════ WIZARD SHELL ═══════════════════ -->
|
||||
<div class="wizard-shell">
|
||||
|
||||
<!-- ══ TOP PROGRESS STRIP ══ -->
|
||||
<div class="progress-strip">
|
||||
<button class="back-to-catalog-btn" id="back-to-catalog" data-i18n-title="wizard.back_catalog" title="Back to catalog">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"/></svg>
|
||||
<span data-i18n="wizard.back_catalog">Operations</span>
|
||||
</button>
|
||||
<div class="progress-divider"></div>
|
||||
<span class="progress-label" data-i18n="wizard.progress.create">Create operation</span>
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-bar-fill"></div>
|
||||
</div>
|
||||
<span class="progress-pct">20%</span>
|
||||
<button class="progress-close" data-i18n-title="wizard.exit" title="Exit wizard" aria-label="Exit wizard">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ══ WIZARD BODY ══ -->
|
||||
<div class="wizard-body">
|
||||
|
||||
<!-- ── Step sidebar ── -->
|
||||
<div class="step-sidebar">
|
||||
<div class="step-sidebar-card">
|
||||
|
||||
<div class="step-sidebar-header">
|
||||
<div class="step-sidebar-logo-mark">M</div>
|
||||
<div class="step-sidebar-brand" data-wizard-brand="create">
|
||||
Create <span>operation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-sidebar-body">
|
||||
<div class="step-sidebar-section-label" data-i18n="wizard.progress.section">Progress</div>
|
||||
|
||||
<div class="steps-list">
|
||||
|
||||
<div class="step-item active">
|
||||
<div class="step-indicator">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-number" data-step="1">Step 1</div>
|
||||
<div class="step-name" data-i18n="wizard.step_name.protocol">Protocol</div>
|
||||
<div class="step-status-text" data-i18n="wizard.status.in_progress">In progress</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item pending">
|
||||
<div class="step-indicator">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-number" data-step="2">Step 2</div>
|
||||
<div class="step-name" data-i18n="wizard.step_name.upstream">Upstream target</div>
|
||||
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item pending" id="sidebar-step-3">
|
||||
<div class="step-indicator">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-number" data-step="3">Step 3</div>
|
||||
<div class="step-name" id="sidebar-step-3-name">Request config</div>
|
||||
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item pending">
|
||||
<div class="step-indicator">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-number" data-step="4">Step 4</div>
|
||||
<div class="step-name" data-i18n="wizard.step_name.tool">Tool config</div>
|
||||
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item pending">
|
||||
<div class="step-indicator">5</div>
|
||||
<div class="step-content">
|
||||
<div class="step-number" data-step="5">Step 5</div>
|
||||
<div class="step-name" data-i18n="wizard.step_name.mapping">Mapping</div>
|
||||
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /steps-list -->
|
||||
|
||||
<div class="sidebar-help" style="margin-top: 20px;">
|
||||
<div class="sidebar-help-title">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 12v-4M8 5v-.5"/>
|
||||
</svg>
|
||||
<span data-i18n="wizard.help.title">Need help?</span>
|
||||
</div>
|
||||
<div class="sidebar-help-text">
|
||||
<span data-i18n="wizard.help.body">Unsure which protocol to choose? Read the</span>
|
||||
<a href="#" class="sidebar-help-link" data-i18n="wizard.help.link">protocol comparison guide →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /step-sidebar-body -->
|
||||
</div><!-- /step-sidebar-card -->
|
||||
</div><!-- /step-sidebar -->
|
||||
|
||||
<!-- ── Main step panel ── -->
|
||||
<div class="wizard-main"><div id="step-panel-container"><!-- steps loaded dynamically --></div></div>
|
||||
</div><!-- /wizard-body -->
|
||||
|
||||
<!-- ══ BOTTOM ACTION BAR ══ -->
|
||||
<div class="action-bar" role="toolbar" aria-label="Wizard navigation">
|
||||
<div class="action-bar-inner">
|
||||
|
||||
<button class="btn btn-back" type="button" disabled>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"/>
|
||||
</svg>
|
||||
<span data-i18n="wizard.button.back">Back</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-save-draft" type="button" data-i18n="wizard.button.save_draft">Save draft</button>
|
||||
|
||||
<div class="action-bar-spacer"></div>
|
||||
|
||||
<span class="step-counter" data-step-counter>Step <strong>1</strong> of 5</span>
|
||||
|
||||
<button id="btn-continue" class="btn btn-continue" type="button">
|
||||
<span data-i18n="wizard.button.continue">Continue</span>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /wizard-shell -->
|
||||
|
||||
<div class="modal-overlay" id="quick-secret-modal" data-testid="wizard-quick-secret-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" data-i18n="wizard.step2.quick_secret_title">Quick create secret</span>
|
||||
<button class="modal-close" id="quick-secret-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" style="display:grid; gap:14px;">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="wizard.step2.quick_secret_name">Secret name</label>
|
||||
<input class="field-input" id="quick-secret-name" data-testid="wizard-quick-secret-name" type="text" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="wizard.step2.quick_secret_kind">Secret kind</label>
|
||||
<select class="field-select" id="quick-secret-kind" data-testid="wizard-quick-secret-kind">
|
||||
<option value="token" data-i18n="secrets.kind.token">Token</option>
|
||||
<option value="header" data-i18n="secrets.kind.header">Header value</option>
|
||||
<option value="generic" data-i18n="secrets.kind.generic">Generic JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="wizard.step2.quick_secret_value">Secret value</label>
|
||||
<input class="field-input" id="quick-secret-value" data-testid="wizard-quick-secret-value" type="text" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="wizard.step2.quick_secret_hint">The plaintext is encrypted immediately and will not be returned again.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="quick-secret-cancel-btn" type="button" data-i18n="btn.cancel">Cancel</button>
|
||||
<button class="btn-primary" id="quick-secret-submit-btn" data-testid="wizard-quick-secret-submit" type="button" data-i18n="wizard.step2.quick_secret_submit">Create secret</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="%CRANK_BUNDLE_WIZARD%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
<!-- ════════════ STEP 1: Protocol ════════════ -->
|
||||
<div id="step-panel-1" class="step-pane">
|
||||
<div class="step-panel-header">
|
||||
<div class="step-panel-eyebrow">
|
||||
<div class="step-panel-eyebrow-line"></div>
|
||||
<span data-step-counter>Step 1 of 5</span>
|
||||
</div>
|
||||
<h1 class="step-panel-title" data-i18n="wizard.step1.title">Choose a protocol</h1>
|
||||
<p class="step-panel-subtitle" data-i18n="wizard.step1.subtitle">Select the transport protocol your upstream service uses. This determines how Crank will communicate with your API and which configuration options are available in subsequent steps.</p>
|
||||
</div>
|
||||
|
||||
<!-- Protocol selection cards -->
|
||||
<div class="protocol-grid" data-testid="wizard-protocol-grid">
|
||||
|
||||
<div class="protocol-card rest selected" data-protocol="rest" data-testid="wizard-protocol-rest" role="radio" aria-checked="true" tabindex="0">
|
||||
<div class="protocol-card-check">
|
||||
<svg viewBox="0 0 12 12"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
|
||||
</div>
|
||||
<div class="protocol-icon-wrap">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#58a6ff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path d="M3.6 9h16.8M3.6 15h16.8"/>
|
||||
<path d="M12 3a15.3 15.3 0 010 18M12 3a15.3 15.3 0 000 18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="protocol-card-name" data-i18n="wizard.step1.rest_name">REST / HTTP</div>
|
||||
<div class="protocol-card-tagline" data-i18n="wizard.step1.rest_tagline">Standard HTTP verbs over a RESTful endpoint. The most broadly supported option.</div>
|
||||
<div class="protocol-card-tags">
|
||||
<span class="protocol-tag">GET</span>
|
||||
<span class="protocol-tag">POST</span>
|
||||
<span class="protocol-tag">PUT</span>
|
||||
<span class="protocol-tag">PATCH</span>
|
||||
<span class="protocol-tag">DELETE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="wizard.protocol_cards.graphql"></div>
|
||||
<div data-slot="wizard.protocol_cards.grpc"></div>
|
||||
<div data-slot="wizard.protocol_cards.soap"></div>
|
||||
<div data-slot="wizard.protocol_cards.websocket"></div>
|
||||
|
||||
</div><!-- /protocol-grid -->
|
||||
|
||||
|
||||
<div class="info-callout" style="margin-bottom: 24px;">
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8,1 15,14 1,14" fill="none"/>
|
||||
<path d="M8 6v4M8 11.5v.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step1.tip_title">You can change this later</div>
|
||||
<div class="info-callout-text">
|
||||
<span data-i18n="wizard.step1.tip_body">Switching protocol after configuring subsequent steps will clear schema and mapping data. Save your operation as a draft before experimenting. See protocol migration guide for details.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-callout" id="wizard-edition-protocol-note" hidden>
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 11V8M8 5v-.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step1.community_protocol_title">Community protocol scope</div>
|
||||
<div class="info-callout-text" id="wizard-edition-protocol-note-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /step-pane-1 -->
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user