Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80868e0f7a | |||
| 6492944c8f | |||
| c2ca6c0b73 | |||
| ec8c0cd891 | |||
| 192204a51c |
+45
-7
@@ -1,9 +1,47 @@
|
|||||||
|
# VCS / tooling
|
||||||
.git
|
.git
|
||||||
node_modules
|
.gitignore
|
||||||
web/node_modules
|
|
||||||
web/build
|
|
||||||
data
|
|
||||||
*.md
|
|
||||||
plans/
|
|
||||||
.claude/
|
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
.gitea/
|
||||||
|
.github/
|
||||||
|
.claude/
|
||||||
|
.code-review-graph/
|
||||||
|
.vex.toml
|
||||||
|
.facts-sync.json
|
||||||
|
.facts-suggestions.md
|
||||||
|
|
||||||
|
# Node / frontend build artifacts (frontend stage rebuilds web/build)
|
||||||
|
node_modules/
|
||||||
|
web/node_modules/
|
||||||
|
web/build/
|
||||||
|
web/.svelte-kit/
|
||||||
|
|
||||||
|
# Runtime / local data
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Compiled binaries (rebuilt inside the image)
|
||||||
|
tinyforge
|
||||||
|
tinyforge.exe
|
||||||
|
tinyforge-server.exe
|
||||||
|
server.exe
|
||||||
|
docker-watcher
|
||||||
|
docker-watcher.exe
|
||||||
|
docker-watcher.exe~
|
||||||
|
/cli
|
||||||
|
/cli.exe
|
||||||
|
|
||||||
|
# Build/orchestration files not needed inside the image
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
Makefile
|
||||||
|
*.example.yaml
|
||||||
|
|
||||||
|
# Docs / planning / design (not needed at runtime)
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
|
plans/
|
||||||
|
design-mockups/
|
||||||
|
test-data/
|
||||||
|
|||||||
+54
-18
@@ -5,34 +5,70 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
frontend:
|
||||||
|
# Skip the build on release-bump commits — the tag push runs release.yml.
|
||||||
|
if: "${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: web
|
||||||
|
run: npm ci --no-audit
|
||||||
|
|
||||||
|
- name: Svelte check
|
||||||
|
working-directory: web
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Unit tests (vitest)
|
||||||
|
working-directory: web
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: web
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
backend:
|
||||||
|
if: "${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: '1.25'
|
||||||
|
cache-dependency-path: go.sum
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
working-directory: web
|
|
||||||
run: npm ci --no-audit
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
working-directory: web
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Vet Go code
|
- name: Vet Go code
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
- name: Build Go binary
|
- name: Run Go tests
|
||||||
run: CGO_ENABLED=0 go build -ldflags="-s -w" -o tinyforge ./cmd/server
|
run: go test ./internal/... -count=1
|
||||||
|
|
||||||
- name: Build Docker image
|
build-image:
|
||||||
run: docker build -t tinyforge:dev .
|
if: "${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}"
|
||||||
|
needs: [frontend, backend]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build Docker image (no push)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
tags: tinyforge:ci-${{ gitea.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
+133
-68
@@ -10,19 +10,109 @@ env:
|
|||||||
REGISTRY: git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge
|
REGISTRY: git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
# Gate the release on a passing test suite. A tagged release must never
|
||||||
|
# ship code that fails `go vet` / `go test`.
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
release_id: ${{ steps.create.outputs.release_id }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Fetch RELEASE_NOTES.md only
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Vet Go code
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Run Go tests
|
||||||
|
run: go test ./internal/... -count=1
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
# Build + push the image FIRST. If this fails, no release is created
|
||||||
|
# (create-release depends on it) — so we never leave an orphan release
|
||||||
|
# pointing at a tag with no published image.
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
build-docker:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compute tags
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
TAG="${{ gitea.ref_name }}"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
# Detect pre-release (alpha/beta/rc) — these do NOT get :latest.
|
||||||
|
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
||||||
|
echo "is_pre=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "is_pre=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.SERVER_HOST }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}:${{ steps.meta.outputs.tag }}
|
||||||
|
${{ env.REGISTRY }}:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.REGISTRY }}:sha-${{ gitea.sha }}
|
||||||
|
${{ steps.meta.outputs.is_pre == 'false' && format('{0}:latest', env.REGISTRY) || '' }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Trigger redeploy webhook
|
||||||
|
if: steps.meta.outputs.is_pre == 'false'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" ]; then
|
||||||
|
echo "Triggering redeploy webhook..."
|
||||||
|
curl -sf -X POST "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" \
|
||||||
|
--max-time 30 || echo "::warning::Redeploy webhook failed"
|
||||||
|
else
|
||||||
|
echo "DOCKER_REDEPLOY_WEBHOOK_URL not set — skipping auto-deploy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
# Create the Gitea release LAST — body = RELEASE_NOTES.md + auto-changelog.
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
create-release:
|
||||||
|
needs: build-docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history for changelog)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
sparse-checkout: RELEASE_NOTES.md
|
fetch-depth: 0
|
||||||
sparse-checkout-cone-mode: false
|
|
||||||
|
- name: Generate changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
PREV_TAG=$(git tag --sort=-v:refname | head -2 | tail -1)
|
||||||
|
if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "${{ gitea.ref_name }}" ]; then
|
||||||
|
git log --oneline --no-decorate -n 20 > /tmp/changelog.txt
|
||||||
|
else
|
||||||
|
git log --oneline --no-decorate "${PREV_TAG}..HEAD" > /tmp/changelog.txt
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Create Gitea release
|
- name: Create Gitea release
|
||||||
id: create
|
|
||||||
env:
|
env:
|
||||||
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -42,74 +132,49 @@ jobs:
|
|||||||
echo "Found RELEASE_NOTES.md"
|
echo "Found RELEASE_NOTES.md"
|
||||||
else
|
else
|
||||||
export RELEASE_NOTES=""
|
export RELEASE_NOTES=""
|
||||||
echo "No RELEASE_NOTES.md found — release will have no body"
|
echo "No RELEASE_NOTES.md found — release body = changelog only"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BODY_JSON=$(python3 -c "
|
# Build release body (notes + changelog) via Python to avoid shell
|
||||||
|
# escaping and CLI length limits.
|
||||||
|
export TAG VERSION IS_PRE
|
||||||
|
python3 <<'PY'
|
||||||
import json, os
|
import json, os
|
||||||
notes = os.environ.get('RELEASE_NOTES', '')
|
|
||||||
print(json.dumps(notes.strip()))
|
|
||||||
")
|
|
||||||
|
|
||||||
# Create release via Gitea API
|
notes = os.environ.get('RELEASE_NOTES', '')
|
||||||
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
changelog = open('/tmp/changelog.txt').read().strip()
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
if notes.strip():
|
||||||
|
sections.append(notes.strip())
|
||||||
|
if changelog:
|
||||||
|
sections.append('## Changelog\n\n' + changelog)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'tag_name': os.environ['TAG'],
|
||||||
|
'name': os.environ['VERSION'],
|
||||||
|
'body': '\n\n'.join(sections),
|
||||||
|
'draft': False,
|
||||||
|
'prerelease': os.environ['IS_PRE'] == 'true',
|
||||||
|
}
|
||||||
|
with open('/tmp/release-payload.json', 'w') as f:
|
||||||
|
json.dump(payload, f)
|
||||||
|
PY
|
||||||
|
|
||||||
|
HTTP=$(curl -s -o /tmp/release-resp.json -w "%{http_code}" \
|
||||||
|
-X POST "$BASE_URL/releases" \
|
||||||
-H "Authorization: token $DEPLOY_TOKEN" \
|
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
--data-binary @/tmp/release-payload.json)
|
||||||
\"tag_name\": \"$TAG\",
|
|
||||||
\"name\": \"$VERSION\",
|
|
||||||
\"body\": $BODY_JSON,
|
|
||||||
\"draft\": false,
|
|
||||||
\"prerelease\": $IS_PRE
|
|
||||||
}")
|
|
||||||
|
|
||||||
# Fallback: if release already exists for this tag, reuse it
|
echo "POST /releases → HTTP $HTTP"
|
||||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
|
if [ "$HTTP" = "201" ]; then
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release-resp.json'))['id'])")
|
||||||
echo "::warning::Release already exists for tag $TAG — reusing existing release"
|
|
||||||
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
|
|
||||||
-H "Authorization: token $DEPLOY_TOKEN")
|
|
||||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
|
||||||
fi
|
|
||||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Created release $RELEASE_ID for $TAG"
|
echo "Created release $RELEASE_ID for $TAG"
|
||||||
|
elif [ "$HTTP" = "409" ] || grep -q "already exists" /tmp/release-resp.json; then
|
||||||
build-docker:
|
echo "::warning::Release already exists for tag $TAG — reusing"
|
||||||
needs: create-release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
|
||||||
id: docker-login
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.DEPLOY_TOKEN }}" | docker login \
|
|
||||||
"$SERVER_HOST" -u "${{ gitea.actor }}" --password-stdin
|
|
||||||
|
|
||||||
- name: Build and tag
|
|
||||||
if: steps.docker-login.outcome == 'success'
|
|
||||||
run: |
|
|
||||||
TAG="${{ gitea.ref_name }}"
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
docker build -t "$REGISTRY:$TAG" -t "$REGISTRY:$VERSION" .
|
|
||||||
# Tag as 'latest' only for stable releases
|
|
||||||
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
|
||||||
docker tag "$REGISTRY:$TAG" "$REGISTRY:latest"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Push
|
|
||||||
if: steps.docker-login.outcome == 'success'
|
|
||||||
run: docker push "$REGISTRY" --all-tags
|
|
||||||
|
|
||||||
- name: Trigger Portainer redeploy
|
|
||||||
if: steps.docker-login.outcome == 'success'
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
if [ -n "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" ]; then
|
|
||||||
echo "Triggering Portainer redeploy..."
|
|
||||||
curl -sf -X POST "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" \
|
|
||||||
--max-time 30 || echo "::warning::Portainer webhook failed"
|
|
||||||
else
|
else
|
||||||
echo "DOCKER_REDEPLOY_WEBHOOK_URL not set — skipping auto-deploy"
|
echo "::error::Failed to create release for $TAG (HTTP $HTTP)"
|
||||||
|
head -c 2000 /tmp/release-resp.json; echo
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
+19
-4
@@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
# Stage 1: Build frontend
|
# Stage 1: Build frontend
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
@@ -9,25 +10,33 @@ COPY web/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Build Go binary
|
# Stage 2: Build Go binary
|
||||||
FROM golang:1.24-alpine AS backend-builder
|
FROM golang:1.25-alpine AS backend-builder
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
ENV GOTOOLCHAIN=auto
|
ENV GOTOOLCHAIN=auto
|
||||||
RUN go mod download
|
# Cache mounts persist the module + build caches across rebuilds (BuildKit).
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
# Copy built frontend into the expected embed location.
|
# Copy built frontend into the expected embed location.
|
||||||
COPY --from=frontend-builder /build/web/build ./web/build
|
COPY --from=frontend-builder /build/web/build ./web/build
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /tinyforge ./cmd/server
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /tinyforge ./cmd/server
|
||||||
|
|
||||||
# Stage 3: Minimal runtime image
|
# Stage 3: Minimal runtime image
|
||||||
FROM alpine:3.19
|
FROM alpine:3.19
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge"
|
||||||
|
LABEL org.opencontainers.image.title="Tinyforge"
|
||||||
|
LABEL org.opencontainers.image.description="Self-hosted Docker deployment + mini-CI platform"
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata wget
|
||||||
|
|
||||||
# Create non-root user.
|
# Create non-root user.
|
||||||
RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app
|
RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app
|
||||||
@@ -46,4 +55,10 @@ EXPOSE 8080
|
|||||||
ENV DATA_DIR=/app/data
|
ENV DATA_DIR=/app/data
|
||||||
ENV LISTEN_ADDR=:8080
|
ENV LISTEN_ADDR=:8080
|
||||||
|
|
||||||
|
VOLUME /app/data
|
||||||
|
|
||||||
|
# /readyz is the public readiness probe (pings the DB); /livez is liveness.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/readyz || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["/app/tinyforge"]
|
ENTRYPOINT ["/app/tinyforge"]
|
||||||
|
|||||||
+11
-2
@@ -1,7 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
tinyforge:
|
tinyforge:
|
||||||
|
# Default: build from source so a fresh clone works out of the box.
|
||||||
build: .
|
build: .
|
||||||
image: tinyforge:latest
|
# Image name doubles as the Gitea registry tag. To DEPLOY the pre-built
|
||||||
|
# image instead of building (e.g. Portainer pulling on a webhook), comment
|
||||||
|
# out `build:` above — compose will then pull this tag. `:latest` is pushed
|
||||||
|
# only for stable (non pre-release) releases, and the registry may require
|
||||||
|
# `docker login git.dolgolyov-family.by` first if the package is private.
|
||||||
|
image: git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge:latest
|
||||||
container_name: tinyforge
|
container_name: tinyforge
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -31,7 +37,10 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- staging-net
|
- staging-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/auth/login"]
|
# /readyz is the public readiness probe (pings the DB, rate-limited).
|
||||||
|
# The previous target (/api/auth/login) is POST-only, so a GET/spider
|
||||||
|
# request returned 405 and the container was always reported unhealthy.
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/readyz"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -100,20 +100,34 @@ func (d *Deployer) SetPreDeployBackuper(b PreDeployBackuper) {
|
|||||||
d.backuper = b
|
d.backuper = b
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaybeBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB snapshot when
|
// maybeBackupBeforeDeploy takes a "pre-deploy" Tinyforge DB snapshot before a
|
||||||
// the setting is enabled. Failures are logged but do not abort the deploy:
|
// deploy when the operator enabled auto_backup_before_deploy. It is called on
|
||||||
// missing a backup is preferable to refusing to ship a fix. Exposed so
|
// the unified deploy path (DispatchPlugin) so the setting actually fires — its
|
||||||
// Source plugins can opt into the same behaviour.
|
// predecessor was orphaned when the legacy executeDeploy pipeline (its only
|
||||||
func (d *Deployer) MaybeBackupBeforeDeploy(deployID string, settings store.Settings) {
|
// caller) was removed in the workload-first cutover, silently disabling the
|
||||||
if !settings.AutoBackupBeforeDeploy || d.backuper == nil {
|
// setting.
|
||||||
|
//
|
||||||
|
// Fail-open: a nil backuper, a settings-load error, or a backup failure all
|
||||||
|
// skip the snapshot without blocking the deploy — missing a backup is
|
||||||
|
// preferable to refusing to ship a fix.
|
||||||
|
func (d *Deployer) maybeBackupBeforeDeploy(workloadID string) {
|
||||||
|
if d.backuper == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err := d.store.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("pre-deploy backup: load settings", "workload", workloadID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !settings.AutoBackupBeforeDeploy {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
backup, err := d.backuper.CreateBackup("pre-deploy")
|
backup, err := d.backuper.CreateBackup("pre-deploy")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("pre-deploy backup failed", "deploy_id", deployID, "error", err)
|
slog.Warn("pre-deploy backup failed", "workload", workloadID, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Info("pre-deploy backup created", "deploy_id", deployID, "backup_id", backup.ID, "filename", backup.Filename)
|
slog.Info("pre-deploy backup created", "workload", workloadID, "backup_id", backup.ID, "filename", backup.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDNSProvider sets the DNS provider for managing DNS records during deployments.
|
// SetDNSProvider sets the DNS provider for managing DNS records during deployments.
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// DispatchPlugin routes a DeploymentIntent for w to the matching Source
|
// DispatchPlugin routes a DeploymentIntent for w to the matching Source
|
||||||
// plugin. This is the new unified deploy path; the legacy executeDeploy
|
// plugin. This is the unified deploy path for every source kind (the legacy
|
||||||
// remains in place until Phase 6 ports image-deploy logic into
|
// executeDeploy pipeline was removed in the workload-first cutover). When the
|
||||||
// source/image. While both exist, callers must pick: webhook/registry
|
// operator enables auto_backup_before_deploy, a pre-deploy Tinyforge DB
|
||||||
// triggers + image deploys still go through the legacy path, while
|
// snapshot is taken here, after the source resolves and before it runs.
|
||||||
// /api/hooks/generic + the unified webhook ingress go through here.
|
|
||||||
func (d *Deployer) DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
func (d *Deployer) DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||||
if err := d.beginDispatch(); err != nil {
|
if err := d.beginDispatch(); err != nil {
|
||||||
metrics.DeploysTotal.Inc(w.SourceKind, "rejected_draining")
|
metrics.DeploysTotal.Inc(w.SourceKind, "rejected_draining")
|
||||||
@@ -29,6 +28,11 @@ func (d *Deployer) DispatchPlugin(ctx context.Context, w plugin.Workload, intent
|
|||||||
metrics.DeploysTotal.Inc("unknown", "unknown_source")
|
metrics.DeploysTotal.Inc("unknown", "unknown_source")
|
||||||
return fmt.Errorf("dispatch %s: %w", w.Name, err)
|
return fmt.Errorf("dispatch %s: %w", w.Name, err)
|
||||||
}
|
}
|
||||||
|
// Optional operator-enabled pre-deploy DB snapshot. Fail-open: never
|
||||||
|
// blocks shipping a deploy. Runs before any source-internal idempotency
|
||||||
|
// check (e.g. the image source's same-tag short-circuit), so a same-tag
|
||||||
|
// redeploy still snapshots — "backup before every deploy attempt".
|
||||||
|
d.maybeBackupBeforeDeploy(w.ID)
|
||||||
err = src.Deploy(ctx, d.PluginDeps(), w, intent)
|
err = src.Deploy(ctx, d.PluginDeps(), w, intent)
|
||||||
outcome := "success"
|
outcome := "success"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package deployer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
|
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeBackuper records pre-deploy backup calls so the dispatch wiring can be
|
||||||
|
// asserted. err (when set) simulates a backup failure.
|
||||||
|
type fakeBackuper struct {
|
||||||
|
count atomic.Int32
|
||||||
|
lastType atomic.Value // string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBackuper) CreateBackup(backupType string) (store.Backup, error) {
|
||||||
|
f.count.Add(1)
|
||||||
|
f.lastType.Store(backupType)
|
||||||
|
if f.err != nil {
|
||||||
|
return store.Backup{}, f.err
|
||||||
|
}
|
||||||
|
return store.Backup{ID: "b1", Filename: "tinyforge-pre-deploy.db"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAutoBackup(t *testing.T, d *Deployer, enabled bool) {
|
||||||
|
t.Helper()
|
||||||
|
s, err := d.store.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get settings: %v", err)
|
||||||
|
}
|
||||||
|
s.AutoBackupBeforeDeploy = enabled
|
||||||
|
if err := d.store.UpdateSettings(s); err != nil {
|
||||||
|
t.Fatalf("update settings: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: the pre-deploy backup hook was orphaned after the cutover (no
|
||||||
|
// caller on DispatchPlugin), making auto_backup_before_deploy a silent no-op.
|
||||||
|
func TestDispatchPlugin_PreDeployBackup_FiresWhenEnabled(t *testing.T) {
|
||||||
|
resetFake(t)
|
||||||
|
d := newTestDeployer(t)
|
||||||
|
b := &fakeBackuper{}
|
||||||
|
d.SetPreDeployBackuper(b)
|
||||||
|
setAutoBackup(t, d, true)
|
||||||
|
|
||||||
|
if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if got := b.count.Load(); got != 1 {
|
||||||
|
t.Fatalf("CreateBackup called %d times, want 1", got)
|
||||||
|
}
|
||||||
|
if bt, _ := b.lastType.Load().(string); bt != "pre-deploy" {
|
||||||
|
t.Fatalf("backup type = %q, want pre-deploy", bt)
|
||||||
|
}
|
||||||
|
if got := dispatchTestSource.deployCount.Load(); got != 1 {
|
||||||
|
t.Fatalf("Deploy ran %d times, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchPlugin_PreDeployBackup_SkippedWhenDisabled(t *testing.T) {
|
||||||
|
resetFake(t)
|
||||||
|
d := newTestDeployer(t)
|
||||||
|
b := &fakeBackuper{}
|
||||||
|
d.SetPreDeployBackuper(b)
|
||||||
|
setAutoBackup(t, d, false)
|
||||||
|
|
||||||
|
if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil {
|
||||||
|
t.Fatalf("dispatch: %v", err)
|
||||||
|
}
|
||||||
|
if got := b.count.Load(); got != 0 {
|
||||||
|
t.Fatalf("CreateBackup called %d times, want 0 (setting off)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchPlugin_PreDeployBackup_NilBackuperNoPanic(t *testing.T) {
|
||||||
|
resetFake(t)
|
||||||
|
d := newTestDeployer(t)
|
||||||
|
setAutoBackup(t, d, true) // enabled, but no backuper wired
|
||||||
|
|
||||||
|
if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil {
|
||||||
|
t.Fatalf("dispatch must not panic/fail with a nil backuper: %v", err)
|
||||||
|
}
|
||||||
|
if got := dispatchTestSource.deployCount.Load(); got != 1 {
|
||||||
|
t.Fatalf("Deploy ran %d times, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchPlugin_PreDeployBackup_FailOpen(t *testing.T) {
|
||||||
|
resetFake(t)
|
||||||
|
d := newTestDeployer(t)
|
||||||
|
b := &fakeBackuper{err: errors.New("disk full")}
|
||||||
|
d.SetPreDeployBackuper(b)
|
||||||
|
setAutoBackup(t, d, true)
|
||||||
|
|
||||||
|
// A failed backup is logged but must NOT block the deploy.
|
||||||
|
if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil {
|
||||||
|
t.Fatalf("deploy must succeed when backup fails (fail-open): %v", err)
|
||||||
|
}
|
||||||
|
if got := dispatchTestSource.deployCount.Load(); got != 1 {
|
||||||
|
t.Fatalf("Deploy ran %d times, want 1 (despite backup failure)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,7 @@
|
|||||||
>{$t('apps.new.imageRefLabel')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}>*</span></span
|
>{$t('apps.new.imageRefLabel')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}>*</span></span
|
||||||
>
|
>
|
||||||
<div class="input-with-button">
|
<div class="input-with-button">
|
||||||
|
<div class="input-wrap">
|
||||||
<input
|
<input
|
||||||
id="app-image-ref"
|
id="app-image-ref"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -231,6 +232,21 @@
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<!--
|
||||||
|
Conflict-lookup affordance lives INSIDE the field as an
|
||||||
|
absolutely-positioned overlay, so a blur → check → clear
|
||||||
|
cycle never reflows the rows below it. (The old inline hint
|
||||||
|
sat in normal flow and flashed in/out, shifting the whole
|
||||||
|
form.) A left fade masks ref text behind it; the aria-live
|
||||||
|
region still announces the lookup to assistive tech.
|
||||||
|
-->
|
||||||
|
{#if enableConflicts && conflictLoading}
|
||||||
|
<span class="conflict-checking" role="status" aria-live="polite">
|
||||||
|
<IconLoader size={12} />
|
||||||
|
<span>{$t('apps.new.imageConflictChecking')}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="discover-btn"
|
class="discover-btn"
|
||||||
@@ -265,18 +281,6 @@
|
|||||||
{:else if inspectStatus === 'error'}
|
{:else if inspectStatus === 'error'}
|
||||||
<span class="discover-pill discover-pill-bad inline">{$t('apps.new.errors.inspectFailed')}</span>
|
<span class="discover-pill discover-pill-bad inline">{$t('apps.new.errors.inspectFailed')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<!--
|
|
||||||
Conflict-checking indicator. Reserves no layout when idle and is a
|
|
||||||
quiet inline hint (not the full panel) while a lookup is in flight,
|
|
||||||
so a no-conflict blur no longer flashes the warning panel in then
|
|
||||||
out. The panel itself renders only for REAL conflicts below.
|
|
||||||
-->
|
|
||||||
{#if enableConflicts && conflictLoading}
|
|
||||||
<span class="conflict-checking" role="status" aria-live="polite">
|
|
||||||
<IconLoader size={12} />
|
|
||||||
<span>{$t('apps.new.imageConflictChecking')}</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</label>
|
</label>
|
||||||
{#if enableConflicts && conflicts.length > 0}
|
{#if enableConflicts && conflicts.length > 0}
|
||||||
<div class="conflict-panel" role="status" aria-live="polite">
|
<div class="conflict-panel" role="status" aria-live="polite">
|
||||||
@@ -551,7 +555,18 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.input-with-button > .input {
|
.input-with-button > .input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* Wrapper exists only to anchor the absolute conflict-checking overlay to
|
||||||
|
the field's box (inputs can't host positioned children themselves). */
|
||||||
|
.input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.input-wrap > .input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -649,20 +664,32 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
/* Quiet inline "checking…" hint shown near the image-ref input while a
|
/* Quiet "checking…" affordance shown while a conflict lookup is in flight.
|
||||||
conflict lookup is in flight. Deliberately NOT the full panel, so a
|
Pinned as an absolute overlay inside the image-ref field's right edge so
|
||||||
no-conflict blur doesn't flash a panel in and out. Self-aligned so it
|
it sits ENTIRELY out of document flow — toggling it on a blur → check →
|
||||||
sits with the inspect status pills without shifting form layout. */
|
clear cycle can no longer reflow the form rows beneath it (the old
|
||||||
|
in-flow hint flashed in/out and shifted the whole form). The left fade
|
||||||
|
lets a long ref scroll cleanly under the pill instead of hard-cutting,
|
||||||
|
and pointer-events:none keeps the field fully clickable underneath. */
|
||||||
.conflict-checking {
|
.conflict-checking {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.5rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
align-self: flex-start;
|
padding: 0.28rem 0.55rem 0.28rem 1.6rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: linear-gradient(90deg, transparent, var(--surface-input) 1.1rem);
|
||||||
font-family: var(--forge-mono);
|
font-family: var(--forge-mono);
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: cc-fade-in 140ms ease-out;
|
||||||
}
|
}
|
||||||
.conflict-checking :global(svg) {
|
.conflict-checking :global(svg) {
|
||||||
animation: spin 0.9s linear infinite;
|
animation: spin 0.9s linear infinite;
|
||||||
@@ -672,6 +699,21 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@keyframes cc-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Respect users who opt out of motion: no spin, no fade. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.conflict-checking,
|
||||||
|
.conflict-checking :global(svg) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
.conflict-heading {
|
.conflict-heading {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Module-scoped resource caches for sidebar list pages.
|
||||||
|
*
|
||||||
|
* Each cache is a stale-while-revalidate store (see `resourceCache.ts`). Pages
|
||||||
|
* read the value via `$store` and call `.refresh()` in `onMount` (and after
|
||||||
|
* mutations). Because these instances live at module scope, their data
|
||||||
|
* survives navigation, so revisiting a tab renders instantly instead of
|
||||||
|
* flashing a skeleton frame.
|
||||||
|
*
|
||||||
|
* Reset them all on logout so a different user never sees the previous
|
||||||
|
* session's cached data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as api from '$lib/api';
|
||||||
|
import type {
|
||||||
|
RedeployTrigger,
|
||||||
|
EventTrigger,
|
||||||
|
MetricAlertRule,
|
||||||
|
LogScanRule,
|
||||||
|
LogScanStats,
|
||||||
|
SharedSecret,
|
||||||
|
AuthSettings,
|
||||||
|
AuthUser
|
||||||
|
} from '$lib/api';
|
||||||
|
import type {
|
||||||
|
Workload,
|
||||||
|
Container,
|
||||||
|
ContainerView,
|
||||||
|
ProxyRoute,
|
||||||
|
App,
|
||||||
|
StaleContainer,
|
||||||
|
Settings,
|
||||||
|
Registry,
|
||||||
|
BackupInfo,
|
||||||
|
DnsRecordView
|
||||||
|
} from '$lib/types';
|
||||||
|
import {
|
||||||
|
createResourceCache,
|
||||||
|
createKeyedResourceCache,
|
||||||
|
type ResourceCache,
|
||||||
|
type KeyedResourceCache
|
||||||
|
} from './resourceCache';
|
||||||
|
import { clearEventsSnapshot } from './eventsSnapshot';
|
||||||
|
|
||||||
|
// ── Single-resource caches ──────────────────────────────────────────────
|
||||||
|
export const workloadsCache = createResourceCache<Workload[]>(
|
||||||
|
(signal) => api.listWorkloads(undefined, signal),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const containersCache = createResourceCache<ContainerView[]>(
|
||||||
|
(signal) => api.listContainers({}, signal),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const proxyRoutesCache = createResourceCache<ProxyRoute[]>(
|
||||||
|
(signal) => api.listProxyRoutes(signal),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const triggersCache = createResourceCache<RedeployTrigger[]>(
|
||||||
|
(signal) => api.listTriggers(undefined, signal),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const eventTriggersCache = createResourceCache<EventTrigger[]>(
|
||||||
|
(signal) => api.listEventTriggers(signal),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const metricAlertRulesCache = createResourceCache<MetricAlertRule[]>(
|
||||||
|
() => api.listMetricAlertRules(),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Composite caches (pages that load several resources together) ─────────
|
||||||
|
export interface LogScanData {
|
||||||
|
rules: LogScanRule[];
|
||||||
|
stats: LogScanStats | null;
|
||||||
|
}
|
||||||
|
export const logScanCache = createResourceCache<LogScanData>(
|
||||||
|
async (signal) => {
|
||||||
|
const [rules, stats] = await Promise.all([
|
||||||
|
api.listLogScanRules(),
|
||||||
|
api.getLogScanStats(signal).catch(() => null)
|
||||||
|
]);
|
||||||
|
return { rules, stats };
|
||||||
|
},
|
||||||
|
{ rules: [], stats: null }
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface SharedSecretsData {
|
||||||
|
secrets: SharedSecret[];
|
||||||
|
apps: App[];
|
||||||
|
}
|
||||||
|
export const sharedSecretsCache = createResourceCache<SharedSecretsData>(
|
||||||
|
async (signal) => {
|
||||||
|
// App load failure is non-fatal — app-scoped rows fall back to a
|
||||||
|
// truncated id when the name lookup misses.
|
||||||
|
const [secrets, apps] = await Promise.all([
|
||||||
|
api.listSharedSecrets(),
|
||||||
|
api.listApps(signal).catch(() => [] as App[])
|
||||||
|
]);
|
||||||
|
return { secrets, apps };
|
||||||
|
},
|
||||||
|
{ secrets: [], apps: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
workloads: Workload[];
|
||||||
|
containers: ContainerView[];
|
||||||
|
stale: StaleContainer[];
|
||||||
|
unusedImagesMB: number;
|
||||||
|
unusedImagesCount: number;
|
||||||
|
unusedImagesExceeded: boolean;
|
||||||
|
}
|
||||||
|
export const dashboardCache = createResourceCache<DashboardData>(
|
||||||
|
async (signal) => {
|
||||||
|
// Each list falls back to empty so a single slow daemon (e.g. Docker
|
||||||
|
// stats) does not blank the whole dashboard.
|
||||||
|
const [workloads, containers, stale] = await Promise.all([
|
||||||
|
api.listWorkloads(undefined, signal),
|
||||||
|
api.listContainers({}, signal).catch(() => [] as ContainerView[]),
|
||||||
|
api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[])
|
||||||
|
]);
|
||||||
|
let unusedImagesMB = 0;
|
||||||
|
let unusedImagesCount = 0;
|
||||||
|
let unusedImagesExceeded = false;
|
||||||
|
try {
|
||||||
|
const s = await api.getUnusedImageStats(signal);
|
||||||
|
unusedImagesMB = s.total_size_mb;
|
||||||
|
unusedImagesCount = s.count;
|
||||||
|
unusedImagesExceeded = s.exceeded;
|
||||||
|
} catch {
|
||||||
|
/* non-critical */
|
||||||
|
}
|
||||||
|
return { workloads, containers, stale, unusedImagesMB, unusedImagesCount, unusedImagesExceeded };
|
||||||
|
},
|
||||||
|
{ workloads: [], containers: [], stale: [], unusedImagesMB: 0, unusedImagesCount: 0, unusedImagesExceeded: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shared global Settings object. Backs both the settings sub-layout (which
|
||||||
|
// derives the proxy provider for its sub-nav) and the settings landing form.
|
||||||
|
// `value` is null until first load — consumers guard with `?.`.
|
||||||
|
export const settingsCache = createResourceCache<Settings | null>(
|
||||||
|
(signal) => api.getSettings(signal),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Settings subpages with their own (non-/settings) data sources.
|
||||||
|
export const registriesCache = createResourceCache<Registry[]>(() => api.listRegistries(), []);
|
||||||
|
export const backupsCache = createResourceCache<BackupInfo[]>(() => api.listBackups(), []);
|
||||||
|
export const authSettingsCache = createResourceCache<AuthSettings | null>(
|
||||||
|
() => api.getAuthSettings(),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
export const usersCache = createResourceCache<AuthUser[]>(
|
||||||
|
async () => (await api.listUsers()) ?? [],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Drill-in LIST pages (single-value SWR) ────────────────────────────────
|
||||||
|
export const staleContainersCache = createResourceCache<StaleContainer[]>(
|
||||||
|
(signal) => api.fetchStaleContainers(signal),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface DnsData {
|
||||||
|
records: DnsRecordView[];
|
||||||
|
wildcardDns: boolean;
|
||||||
|
}
|
||||||
|
export const dnsCache = createResourceCache<DnsData>(
|
||||||
|
async (signal) => {
|
||||||
|
const [s, records] = await Promise.all([api.getSettings(signal), api.getDnsRecords()]);
|
||||||
|
return { records, wildcardDns: s.wildcard_dns ?? true };
|
||||||
|
},
|
||||||
|
{ records: [], wildcardDns: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── DETAIL pages (keyed by route param) ───────────────────────────────────
|
||||||
|
// Per-id caches so revisiting a detail (or navigating A→B→A) renders the
|
||||||
|
// cached entity instantly while revalidating. Detail-FORM pages seed their
|
||||||
|
// editable fields once per id from these (see the pages for the guard).
|
||||||
|
// apps/[id] warm-seeds BOTH the workload and its containers so the live-status
|
||||||
|
// badge (which reads containers.length) shows last-known state on revisit
|
||||||
|
// rather than flashing a wrong "not deployed" before the fetch resolves.
|
||||||
|
export interface WorkloadDetailSeed {
|
||||||
|
workload: Workload;
|
||||||
|
containers: Container[];
|
||||||
|
}
|
||||||
|
export const workloadDetailCache = createKeyedResourceCache<WorkloadDetailSeed>(
|
||||||
|
async (key, signal) => ({
|
||||||
|
workload: await api.getWorkload(key, signal),
|
||||||
|
containers: await api.listWorkloadContainers(key, signal)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
export const triggerDetailCache = createKeyedResourceCache<RedeployTrigger>(
|
||||||
|
(key, signal) => api.getTrigger(key, signal)
|
||||||
|
);
|
||||||
|
export const eventTriggerDetailCache = createKeyedResourceCache<EventTrigger>(
|
||||||
|
(key, signal) => api.getEventTrigger(Number(key), signal)
|
||||||
|
);
|
||||||
|
export const logScanRuleDetailCache = createKeyedResourceCache<LogScanRule>(
|
||||||
|
(key, signal) => api.getLogScanRule(Number(key), signal)
|
||||||
|
);
|
||||||
|
export const metricAlertRuleDetailCache = createKeyedResourceCache<MetricAlertRule>(
|
||||||
|
(key, signal) => api.getMetricAlertRule(Number(key), signal)
|
||||||
|
);
|
||||||
|
export const sharedSecretDetailCache = createKeyedResourceCache<SharedSecret>(
|
||||||
|
(key, signal) => api.getSharedSecret(key, signal)
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyedCaches: KeyedResourceCache<unknown>[] = [
|
||||||
|
workloadDetailCache,
|
||||||
|
triggerDetailCache,
|
||||||
|
eventTriggerDetailCache,
|
||||||
|
logScanRuleDetailCache,
|
||||||
|
metricAlertRuleDetailCache,
|
||||||
|
sharedSecretDetailCache
|
||||||
|
] as KeyedResourceCache<unknown>[];
|
||||||
|
|
||||||
|
/** Every cache, for bulk reset on logout. */
|
||||||
|
const allCaches: ResourceCache<unknown>[] = [
|
||||||
|
workloadsCache,
|
||||||
|
containersCache,
|
||||||
|
proxyRoutesCache,
|
||||||
|
triggersCache,
|
||||||
|
eventTriggersCache,
|
||||||
|
metricAlertRulesCache,
|
||||||
|
logScanCache,
|
||||||
|
sharedSecretsCache,
|
||||||
|
dashboardCache,
|
||||||
|
settingsCache,
|
||||||
|
registriesCache,
|
||||||
|
backupsCache,
|
||||||
|
authSettingsCache,
|
||||||
|
usersCache,
|
||||||
|
staleContainersCache,
|
||||||
|
dnsCache
|
||||||
|
] as ResourceCache<unknown>[];
|
||||||
|
|
||||||
|
export function resetAllCaches(): void {
|
||||||
|
for (const c of allCaches) c.reset();
|
||||||
|
for (const c of keyedCaches) c.reset();
|
||||||
|
clearEventsSnapshot();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Warm-start snapshot for the Event Log page.
|
||||||
|
*
|
||||||
|
* The events page can't use the standard resource cache (`caches.ts`) because
|
||||||
|
* its list is paginated, mutated by live SSE pushes, and edited by deletes —
|
||||||
|
* far more than a fetch-and-replace list. Instead it keeps working in local
|
||||||
|
* component state and mirrors the latest list/stats here. On revisit the page
|
||||||
|
* seeds from this snapshot so it paints the last-known events immediately
|
||||||
|
* instead of flashing a skeleton, then refreshes in the background.
|
||||||
|
*
|
||||||
|
* Module-scoped (survives navigation, cleared on logout via `resetAllCaches`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EventLogEntry, EventLogStats } from '$lib/types';
|
||||||
|
|
||||||
|
export interface EventsSnapshot {
|
||||||
|
events: EventLogEntry[];
|
||||||
|
stats: EventLogStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot: EventsSnapshot | null = null;
|
||||||
|
|
||||||
|
export function getEventsSnapshot(): EventsSnapshot | null {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveEventsSnapshot(s: EventsSnapshot): void {
|
||||||
|
snapshot = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearEventsSnapshot(): void {
|
||||||
|
snapshot = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Stale-while-revalidate resource cache.
|
||||||
|
*
|
||||||
|
* Backs list pages so that switching between sidebar tabs doesn't flash an
|
||||||
|
* empty/skeleton frame on every visit. The first visit to a page does a cold
|
||||||
|
* load (skeleton shown once); every subsequent visit renders the previously
|
||||||
|
* loaded value immediately while a background refresh keeps it current.
|
||||||
|
*
|
||||||
|
* The cache is module-scoped (one instance per resource, created in
|
||||||
|
* `caches.ts`) so its value survives component unmount/remount across
|
||||||
|
* client-side navigation — that persistence is what removes the flicker.
|
||||||
|
*
|
||||||
|
* Style mirrors `navCounts.ts`: a classic `writable` exposed as a `Readable`
|
||||||
|
* plus imperative `refresh()`/`set()` methods, so pages keep using `$store`
|
||||||
|
* auto-subscription.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, get, type Readable } from 'svelte/store';
|
||||||
|
|
||||||
|
export interface ResourceState<T> {
|
||||||
|
/** Last successfully loaded value (or the initial seed before first load). */
|
||||||
|
value: T;
|
||||||
|
/**
|
||||||
|
* True only during the *cold* load — i.e. the resource has never loaded
|
||||||
|
* successfully and there is no error yet. Drives the skeleton. Stays false
|
||||||
|
* for background refreshes so revisits never flash.
|
||||||
|
*/
|
||||||
|
loading: boolean;
|
||||||
|
/** True while any fetch (cold or background) is in flight. */
|
||||||
|
refreshing: boolean;
|
||||||
|
/** Last error message, or '' when the most recent fetch succeeded. */
|
||||||
|
error: string;
|
||||||
|
/** Whether at least one fetch has ever succeeded. */
|
||||||
|
loaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceCache<T> extends Readable<ResourceState<T>> {
|
||||||
|
/**
|
||||||
|
* (Re)fetch the resource. Aborts any in-flight fetch so the latest call
|
||||||
|
* wins — important after a mutation, where a stale background refresh must
|
||||||
|
* not clobber fresh data. Safe to call from `onMount` on every visit.
|
||||||
|
*/
|
||||||
|
refresh(): Promise<void>;
|
||||||
|
/** Optimistically replace the cached value (e.g. after a local mutation). */
|
||||||
|
set(value: T): void;
|
||||||
|
/** Clear back to the cold/initial state (e.g. on logout). */
|
||||||
|
reset(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResourceCache<T>(
|
||||||
|
fetcher: (signal?: AbortSignal) => Promise<T>,
|
||||||
|
initial: T
|
||||||
|
): ResourceCache<T> {
|
||||||
|
const seed: ResourceState<T> = {
|
||||||
|
value: initial,
|
||||||
|
loading: true,
|
||||||
|
refreshing: false,
|
||||||
|
error: '',
|
||||||
|
loaded: false
|
||||||
|
};
|
||||||
|
const store = writable<ResourceState<T>>(seed);
|
||||||
|
|
||||||
|
let inFlight: AbortController | null = null;
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
// Latest-wins: cancel a previous in-flight fetch so a slow background
|
||||||
|
// refresh can't overwrite a newer (e.g. post-mutation) result.
|
||||||
|
if (inFlight) inFlight.abort();
|
||||||
|
const ac = new AbortController();
|
||||||
|
inFlight = ac;
|
||||||
|
|
||||||
|
store.update((s) => ({ ...s, refreshing: true, error: '' }));
|
||||||
|
try {
|
||||||
|
const value = await fetcher(ac.signal);
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
store.set({ value, loading: false, refreshing: false, error: '', loaded: true });
|
||||||
|
} catch (e) {
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
const error = e instanceof Error ? e.message : String(e);
|
||||||
|
// Keep the last good value (stale-while-revalidate) and surface the
|
||||||
|
// error; clear `loading` so the cold skeleton resolves to either data
|
||||||
|
// or an error state rather than spinning forever.
|
||||||
|
store.update((s) => ({ ...s, loading: false, refreshing: false, error }));
|
||||||
|
} finally {
|
||||||
|
if (inFlight === ac) inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(value: T): void {
|
||||||
|
store.update((s) => ({ ...s, value, loaded: true, loading: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
if (inFlight) {
|
||||||
|
inFlight.abort();
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
store.set({ ...seed, value: initial });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe: store.subscribe, refresh, set, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Keyed (per-id) variant — for detail pages fetched by a route param.
|
||||||
|
//
|
||||||
|
// A single-value cache can't serve detail pages (each `[id]` is different
|
||||||
|
// data). This holds a Map<id, state> so revisiting (or A→B→A navigating) a
|
||||||
|
// detail renders the cached entity instantly while revalidating that id.
|
||||||
|
// Pages read reactively via `$cache.get(id)` (the store value is the Map) and
|
||||||
|
// call `cache.refresh(id)` / `cache.peek(id)` (methods on the object).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface KeyedResourceState<T> {
|
||||||
|
/** Last loaded value for this key, or null before its first load. */
|
||||||
|
value: T | null;
|
||||||
|
/** True on the cold load for this key (never loaded, no error). */
|
||||||
|
loading: boolean;
|
||||||
|
/** True while a fetch for this key is in flight. */
|
||||||
|
refreshing: boolean;
|
||||||
|
error: string;
|
||||||
|
loaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coldKeyed<T>(): KeyedResourceState<T> {
|
||||||
|
return { value: null, loading: true, refreshing: false, error: '', loaded: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyedResourceCache<T> extends Readable<Map<string, KeyedResourceState<T>>> {
|
||||||
|
/** (Re)fetch one key; aborts a prior in-flight fetch for the same key. */
|
||||||
|
refresh(key: string): Promise<void>;
|
||||||
|
/** Optimistically replace one key's value (e.g. after a mutation). */
|
||||||
|
set(key: string, value: T): void;
|
||||||
|
/** Non-reactive snapshot read; returns the cold state if the key is absent. */
|
||||||
|
peek(key: string): KeyedResourceState<T>;
|
||||||
|
/** Drop a single key (e.g. after deleting that entity) so a later visit
|
||||||
|
* doesn't warm-seed a stale/phantom value. */
|
||||||
|
remove(key: string): void;
|
||||||
|
/** Drop all keys (e.g. on logout). */
|
||||||
|
reset(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKeyedResourceCache<T>(
|
||||||
|
fetcher: (key: string, signal?: AbortSignal) => Promise<T>
|
||||||
|
): KeyedResourceCache<T> {
|
||||||
|
const store = writable<Map<string, KeyedResourceState<T>>>(new Map());
|
||||||
|
const inFlight = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
function patch(key: string, partial: Partial<KeyedResourceState<T>>): void {
|
||||||
|
store.update((m) => {
|
||||||
|
const next = new Map(m);
|
||||||
|
next.set(key, { ...(next.get(key) ?? coldKeyed<T>()), ...partial });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(key: string): Promise<void> {
|
||||||
|
const prev = inFlight.get(key);
|
||||||
|
if (prev) prev.abort();
|
||||||
|
const ac = new AbortController();
|
||||||
|
inFlight.set(key, ac);
|
||||||
|
|
||||||
|
patch(key, { refreshing: true, error: '' });
|
||||||
|
try {
|
||||||
|
const value = await fetcher(key, ac.signal);
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
patch(key, { value, loaded: true, loading: false, refreshing: false, error: '' });
|
||||||
|
} catch (e) {
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
patch(key, {
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
error: e instanceof Error ? e.message : String(e)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (inFlight.get(key) === ac) inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(key: string, value: T): void {
|
||||||
|
patch(key, { value, loaded: true, loading: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function peek(key: string): KeyedResourceState<T> {
|
||||||
|
return get(store).get(key) ?? coldKeyed<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(key: string): void {
|
||||||
|
const ac = inFlight.get(key);
|
||||||
|
if (ac) {
|
||||||
|
ac.abort();
|
||||||
|
inFlight.delete(key);
|
||||||
|
}
|
||||||
|
store.update((m) => {
|
||||||
|
if (!m.has(key)) return m;
|
||||||
|
const next = new Map(m);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
for (const ac of inFlight.values()) ac.abort();
|
||||||
|
inFlight.clear();
|
||||||
|
store.set(new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe: store.subscribe, refresh, set, peek, remove, reset };
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import { logout as apiLogout } from '$lib/api';
|
import { logout as apiLogout } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { navCounts, startNavCountsPolling, stopNavCountsPolling, refreshNavCounts } from '$lib/stores/navCounts';
|
import { navCounts, startNavCountsPolling, stopNavCountsPolling, refreshNavCounts } from '$lib/stores/navCounts';
|
||||||
|
import { resetAllCaches } from '$lib/stores/caches';
|
||||||
import { health, startHealthPolling, stopHealthPolling, refreshHealth } from '$lib/stores/health';
|
import { health, startHealthPolling, stopHealthPolling, refreshHealth } from '$lib/stores/health';
|
||||||
import { effectiveTimezone, formatOffsetLabel } from '$lib/stores/timezone';
|
import { effectiveTimezone, formatOffsetLabel } from '$lib/stores/timezone';
|
||||||
import { fmt } from '$lib/format/datetime';
|
import { fmt } from '$lib/format/datetime';
|
||||||
@@ -357,6 +358,7 @@
|
|||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
try { await apiLogout(); } catch { /* best effort */ }
|
try { await apiLogout(); } catch { /* best effort */ }
|
||||||
clearAuth();
|
clearAuth();
|
||||||
|
resetAllCaches();
|
||||||
goto('/login');
|
goto('/login');
|
||||||
}}
|
}}
|
||||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
|
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
|
||||||
|
|||||||
+15
-46
@@ -6,8 +6,7 @@
|
|||||||
// We no longer fan out N+1 fetches per project to gather instance
|
// We no longer fan out N+1 fetches per project to gather instance
|
||||||
// status — the global containers index already carries the workload
|
// status — the global containers index already carries the workload
|
||||||
// reference and state.
|
// reference and state.
|
||||||
import type { ContainerView, StaleContainer, Workload } from '$lib/types';
|
import { dashboardCache } from '$lib/stores/caches';
|
||||||
import * as api from '$lib/api';
|
|
||||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
||||||
@@ -20,54 +19,24 @@
|
|||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { fmt } from '$lib/format/datetime';
|
import { fmt } from '$lib/format/datetime';
|
||||||
|
|
||||||
let workloads = $state<Workload[]>([]);
|
// Cache-backed (stale-while-revalidate) so switching back to the dashboard
|
||||||
let containers = $state<ContainerView[]>([]);
|
// renders the last-known figures immediately instead of flashing skeleton
|
||||||
let staleContainers = $state<StaleContainer[]>([]);
|
// cards. The composite cache fans out the same parallel reads (each with an
|
||||||
let unusedImagesMB = $state(0);
|
// empty fallback) and aborts a prior in-flight refresh on the next call.
|
||||||
let unusedImagesCount = $state(0);
|
// See caches.ts.
|
||||||
let unusedImagesExceeded = $state(false);
|
const workloads = $derived($dashboardCache.value.workloads);
|
||||||
let loading = $state(true);
|
const containers = $derived($dashboardCache.value.containers);
|
||||||
let error = $state('');
|
const staleContainers = $derived($dashboardCache.value.stale);
|
||||||
let loadController: AbortController | null = null;
|
const unusedImagesMB = $derived($dashboardCache.value.unusedImagesMB);
|
||||||
|
const unusedImagesCount = $derived($dashboardCache.value.unusedImagesCount);
|
||||||
|
const unusedImagesExceeded = $derived($dashboardCache.value.unusedImagesExceeded);
|
||||||
|
const loading = $derived($dashboardCache.loading);
|
||||||
|
const error = $derived($dashboardCache.error);
|
||||||
|
|
||||||
async function loadDashboard() {
|
const loadDashboard = () => dashboardCache.refresh();
|
||||||
loadController?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
loadController = controller;
|
|
||||||
const signal = controller.signal;
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
// Parallelize the cheap top-level reads. Each falls back to an
|
|
||||||
// empty list so a single slow daemon (e.g. Docker stats) does
|
|
||||||
// not blank the entire dashboard.
|
|
||||||
const [wls, ctrs, stale] = await Promise.all([
|
|
||||||
api.listWorkloads(undefined, signal),
|
|
||||||
api.listContainers({}, signal).catch(() => [] as ContainerView[]),
|
|
||||||
api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[])
|
|
||||||
]);
|
|
||||||
workloads = wls;
|
|
||||||
containers = ctrs;
|
|
||||||
staleContainers = stale;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const imgStats = await api.getUnusedImageStats(signal);
|
|
||||||
unusedImagesMB = imgStats.total_size_mb;
|
|
||||||
unusedImagesCount = imgStats.count;
|
|
||||||
unusedImagesExceeded = imgStats.exceeded;
|
|
||||||
} catch { /* non-critical */ }
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
|
||||||
error = e instanceof Error ? e.message : $t('dashboard.loadFailed');
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
return () => { loadController?.abort(); };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Plugin-native workloads only. Legacy pre-cutover rows (kind project/
|
// Plugin-native workloads only. Legacy pre-cutover rows (kind project/
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { Workload } from '$lib/types';
|
|
||||||
import * as api from '$lib/api';
|
|
||||||
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
|
import { workloadsCache } from '$lib/stores/caches';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
let workloads = $state<Workload[]>([]);
|
// Cache-backed (stale-while-revalidate): the value persists across
|
||||||
let loading = $state(true);
|
// navigation, so revisiting this tab renders the table immediately instead
|
||||||
let error = $state('');
|
// of flashing the cold skeleton. The skeleton only shows on the very first
|
||||||
|
// visit of the session.
|
||||||
|
const workloads = $derived($workloadsCache.value);
|
||||||
|
const loading = $derived($workloadsCache.loading);
|
||||||
|
const error = $derived($workloadsCache.error);
|
||||||
let filter = $state<'all' | string>('all');
|
let filter = $state<'all' | string>('all');
|
||||||
|
|
||||||
// Plugin-native rows are those with a source_kind. trigger_kind is no
|
// Plugin-native rows are those with a source_kind. trigger_kind is no
|
||||||
@@ -27,17 +30,7 @@
|
|||||||
pluginRows.filter((w) => w.source_kind === kind).length
|
pluginRows.filter((w) => w.source_kind === kind).length
|
||||||
);
|
);
|
||||||
|
|
||||||
async function load() {
|
const load = () => workloadsCache.refresh();
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
workloads = await api.listWorkloads();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : $t('apps.list.loadError');
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceBadge(kind: string): string {
|
function sourceBadge(kind: string): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import type { Container, EventLogEntry, PluginWorkloadInput, Workload } from '$lib/types';
|
import type { Container, EventLogEntry, PluginWorkloadInput, Workload } from '$lib/types';
|
||||||
import type { RedeployTrigger, WorkloadTriggerBinding } from '$lib/api';
|
import type { RedeployTrigger, WorkloadTriggerBinding } from '$lib/api';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
@@ -65,15 +66,26 @@
|
|||||||
import { fmt } from '$lib/format/datetime';
|
import { fmt } from '$lib/format/datetime';
|
||||||
import { formatBytes } from '$lib/format/bytes';
|
import { formatBytes } from '$lib/format/bytes';
|
||||||
import { connectGlobalEvents, toEventLogEntry, type SSEConnection } from '$lib/sse';
|
import { connectGlobalEvents, toEventLogEntry, type SSEConnection } from '$lib/sse';
|
||||||
|
import { workloadDetailCache } from '$lib/stores/caches';
|
||||||
|
|
||||||
// Route params come back as `string | undefined`; the route file
|
// Route params come back as `string | undefined`; the route file
|
||||||
// guarantees `id` exists, but the empty-string fallback satisfies
|
// guarantees `id` exists, but the empty-string fallback satisfies
|
||||||
// the type checker — server validation rejects empty ids anyway.
|
// the type checker — server validation rejects empty ids anyway.
|
||||||
const id = $derived($page.params.id ?? '');
|
const id = $derived($page.params.id ?? '');
|
||||||
|
|
||||||
let workload = $state<Workload | null>(null);
|
// Warm-seed the workload from the per-id cache so the body — gated on
|
||||||
let containers = $state<Container[]>([]);
|
// `loading && !workload` — skips the full-page skeleton on revisit (the
|
||||||
let loading = $state(true);
|
// other sections populate when load() resolves). Seeding synchronously at
|
||||||
|
// init (not just in the load effect) avoids even a one-frame skeleton on a
|
||||||
|
// fresh mount. Edit-mode forms seed on-demand from `workload`, so there is
|
||||||
|
// no on-load form state to clobber.
|
||||||
|
const _seed = workloadDetailCache.peek(get(page).params.id ?? '').value;
|
||||||
|
let workload = $state<Workload | null>(_seed?.workload ?? null);
|
||||||
|
// Containers are seeded too (not just the workload) so the live-status badge
|
||||||
|
// + Stop/Start toolbar reflect last-known state on a warm revisit instead of
|
||||||
|
// momentarily reading the empty array as "not deployed".
|
||||||
|
let containers = $state<Container[]>(_seed?.containers ?? []);
|
||||||
|
let loading = $state(_seed == null);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let deploying = $state(false);
|
let deploying = $state(false);
|
||||||
let deployRef = $state('');
|
let deployRef = $state('');
|
||||||
@@ -903,7 +915,28 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const k = id;
|
||||||
|
// Reused-component nav (A→B): warm-seed B's header instantly if cached;
|
||||||
|
// a cold id keeps the skeleton until the fetch resolves.
|
||||||
|
const cached = workloadDetailCache.peek(k);
|
||||||
|
if (cached.value) {
|
||||||
|
workload = cached.value.workload;
|
||||||
|
containers = cached.value.containers;
|
||||||
|
// Reset the non-seeded dependent panels so a reused-component warm
|
||||||
|
// nav (A→B) never renders the previous id's chain / bindings / rules
|
||||||
|
// / env / volumes under the new id's header; they repopulate when
|
||||||
|
// this id's Promise.all below resolves.
|
||||||
|
chain = null;
|
||||||
|
bindings = [];
|
||||||
|
logRules = [];
|
||||||
|
envRows = [];
|
||||||
|
volumeRows = [];
|
||||||
|
chainError = '';
|
||||||
|
logRulesError = '';
|
||||||
|
bindingsError = '';
|
||||||
|
} else {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
}
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const [w, c, env, vols, ch, lr, bs] = await Promise.all([
|
const [w, c, env, vols, ch, lr, bs] = await Promise.all([
|
||||||
@@ -924,8 +957,12 @@
|
|||||||
return [] as WorkloadTriggerBinding[];
|
return [] as WorkloadTriggerBinding[];
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
// Bail if the route id changed mid-flight — a newer load() owns the
|
||||||
|
// page state and applying this stale result would clobber it.
|
||||||
|
if (id !== k) return;
|
||||||
workload = w;
|
workload = w;
|
||||||
containers = c;
|
containers = c;
|
||||||
|
workloadDetailCache.set(k, { workload: w, containers: c });
|
||||||
envRows = env;
|
envRows = env;
|
||||||
volumeRows = vols;
|
volumeRows = vols;
|
||||||
chain = ch;
|
chain = ch;
|
||||||
@@ -970,9 +1007,16 @@
|
|||||||
// session storage may be disabled — ignore.
|
// session storage may be disabled — ignore.
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
|
// Clear the (possibly warm-seeded) workload so a 404 / failed load
|
||||||
|
// resolves to the clean error page instead of a phantom, interactive
|
||||||
|
// detail UI for an entity that no longer exists.
|
||||||
|
workload = null;
|
||||||
error = e instanceof Error ? e.message : $t('apps.detail.loadError');
|
error = e instanceof Error ? e.message : $t('apps.detail.loadError');
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
// Only the current id's load may clear the skeleton — a stale load
|
||||||
|
// returning early must not flip a newer load's loading state.
|
||||||
|
if (id === k) loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,6 +1435,9 @@
|
|||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
await api.deletePluginWorkload(id);
|
await api.deletePluginWorkload(id);
|
||||||
|
// Drop the cached entry so navigating back to this (now-deleted) id
|
||||||
|
// doesn't warm-seed a phantom detail page.
|
||||||
|
workloadDetailCache.remove(id);
|
||||||
goto('/apps');
|
goto('/apps');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : $t('apps.detail.deleteError');
|
error = e instanceof Error ? e.message : $t('apps.detail.deleteError');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import type { ContainerView, WorkloadKind } from '$lib/types';
|
import type { ContainerView, WorkloadKind } from '$lib/types';
|
||||||
import * as api from '$lib/api';
|
import { containersCache } from '$lib/stores/caches';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
@@ -14,10 +14,15 @@
|
|||||||
// allContainers holds the unfiltered list — kind/state filters are applied
|
// allContainers holds the unfiltered list — kind/state filters are applied
|
||||||
// client-side so the tab counters reflect the whole population, not the
|
// client-side so the tab counters reflect the whole population, not the
|
||||||
// current narrowed view (otherwise picking "Project" would show All=0).
|
// current narrowed view (otherwise picking "Project" would show All=0).
|
||||||
let allContainers = $state<ContainerView[]>([]);
|
//
|
||||||
let loading = $state(true);
|
// Cache-backed (stale-while-revalidate): the list persists across
|
||||||
let refreshing = $state(false);
|
// navigation, so revisiting this tab renders immediately instead of
|
||||||
let error = $state('');
|
// flashing the cold skeleton. The cache's abort-on-refresh replaces the
|
||||||
|
// previous manual `loadSeq` race guard.
|
||||||
|
const allContainers = $derived($containersCache.value);
|
||||||
|
const loading = $derived($containersCache.loading);
|
||||||
|
const refreshing = $derived($containersCache.refreshing);
|
||||||
|
const error = $derived($containersCache.error);
|
||||||
|
|
||||||
// Filters seed from query string so the tab is shareable / refresh-safe.
|
// Filters seed from query string so the tab is shareable / refresh-safe.
|
||||||
const initialKind = (() => {
|
const initialKind = (() => {
|
||||||
@@ -32,24 +37,9 @@
|
|||||||
let stateFilter = $state(initialState);
|
let stateFilter = $state(initialState);
|
||||||
let searchTerm = $state(initialQ);
|
let searchTerm = $state(initialQ);
|
||||||
|
|
||||||
async function load(initial: boolean): Promise<void> {
|
// `initial` is retained for call-site compatibility; the cache distinguishes
|
||||||
if (initial) loading = true;
|
// the cold load (skeleton) from background refreshes on its own.
|
||||||
else refreshing = true;
|
const load = (_initial = false): Promise<void> => containersCache.refresh();
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
// Race-safety: keep the latest fetch's result and discard stragglers.
|
|
||||||
const seq = ++loadSeq;
|
|
||||||
const containers = await api.listContainers({});
|
|
||||||
if (seq !== loadSeq) return;
|
|
||||||
allContainers = containers;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : $t('containers.errLoad');
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
refreshing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let loadSeq = 0;
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void load(true);
|
void load(true);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { StaleContainer } from '$lib/types';
|
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
|
import { staleContainersCache } from '$lib/stores/caches';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||||
@@ -10,37 +10,19 @@
|
|||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { fmt } from '$lib/format/datetime';
|
import { fmt } from '$lib/format/datetime';
|
||||||
|
|
||||||
let containers = $state<StaleContainer[]>([]);
|
// Cache-backed (stale-while-revalidate) so revisiting renders instantly
|
||||||
let loading = $state(true);
|
// instead of flashing the cold skeleton; the cache aborts a prior in-flight
|
||||||
let error = $state('');
|
// fetch on the next refresh (replacing the old manual loadController guard).
|
||||||
|
const containers = $derived($staleContainersCache.value);
|
||||||
|
const loading = $derived($staleContainersCache.loading);
|
||||||
|
const error = $derived($staleContainersCache.error);
|
||||||
|
|
||||||
let confirmSingleId = $state('');
|
let confirmSingleId = $state('');
|
||||||
let confirmBulk = $state(false);
|
let confirmBulk = $state(false);
|
||||||
let cleaningIds = $state<Set<string>>(new Set());
|
let cleaningIds = $state<Set<string>>(new Set());
|
||||||
let bulkCleaning = $state(false);
|
let bulkCleaning = $state(false);
|
||||||
|
|
||||||
let loadController: AbortController | null = null;
|
const loadStale = () => staleContainersCache.refresh();
|
||||||
|
|
||||||
async function loadStale() {
|
|
||||||
loadController?.abort();
|
|
||||||
const ac = new AbortController();
|
|
||||||
loadController = ac;
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const rows = await api.fetchStaleContainers(ac.signal);
|
|
||||||
if (ac.signal.aborted) return;
|
|
||||||
containers = rows;
|
|
||||||
} catch (e) {
|
|
||||||
if (ac.signal.aborted) return;
|
|
||||||
error = e instanceof Error ? e.message : $t('stale.loadFailed');
|
|
||||||
} finally {
|
|
||||||
if (loadController === ac) {
|
|
||||||
loading = false;
|
|
||||||
loadController = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestCleanup(id: string) {
|
function requestCleanup(id: string) {
|
||||||
confirmSingleId = id;
|
confirmSingleId = id;
|
||||||
@@ -52,7 +34,7 @@
|
|||||||
cleaningIds = new Set([...cleaningIds, id]);
|
cleaningIds = new Set([...cleaningIds, id]);
|
||||||
try {
|
try {
|
||||||
await api.cleanupStaleContainer(id);
|
await api.cleanupStaleContainer(id);
|
||||||
containers = containers.filter((c) => c.container.id !== id);
|
await staleContainersCache.refresh();
|
||||||
toasts.success($t('stale.cleanedUp'));
|
toasts.success($t('stale.cleanedUp'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toasts.error(e instanceof Error ? e.message : $t('stale.cleanupFailed'));
|
toasts.error(e instanceof Error ? e.message : $t('stale.cleanupFailed'));
|
||||||
@@ -68,7 +50,7 @@
|
|||||||
bulkCleaning = true;
|
bulkCleaning = true;
|
||||||
try {
|
try {
|
||||||
const result = await api.bulkCleanupStaleContainers();
|
const result = await api.bulkCleanupStaleContainers();
|
||||||
containers = [];
|
await staleContainersCache.refresh();
|
||||||
toasts.success($t('stale.bulkCleanedUp', { count: String(result.deleted) }));
|
toasts.success($t('stale.bulkCleanedUp', { count: String(result.deleted) }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toasts.error(e instanceof Error ? e.message : $t('stale.cleanupFailed'));
|
toasts.error(e instanceof Error ? e.message : $t('stale.cleanupFailed'));
|
||||||
@@ -79,7 +61,6 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadStale();
|
loadStale();
|
||||||
return () => loadController?.abort();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getDnsRecords, syncDnsRecords, deleteDnsRecord, getSettings } from '$lib/api';
|
import { get } from 'svelte/store';
|
||||||
import type { DnsRecordView } from '$lib/types';
|
import { syncDnsRecords, deleteDnsRecord } from '$lib/api';
|
||||||
|
import { dnsCache } from '$lib/stores/caches';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconSearch, IconRefresh, IconTrash, IconLoader } from '$lib/components/icons';
|
import { IconSearch, IconRefresh, IconTrash, IconLoader } from '$lib/components/icons';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
|
|
||||||
let loading = $state(true);
|
// Cache-backed (stale-while-revalidate) so revisiting renders records
|
||||||
let records = $state<DnsRecordView[]>([]);
|
// instantly instead of flashing the cold skeleton. See caches.ts.
|
||||||
let wildcardDns = $state(true);
|
const records = $derived($dnsCache.value.records);
|
||||||
|
const wildcardDns = $derived($dnsCache.value.wildcardDns);
|
||||||
|
const loading = $derived($dnsCache.loading);
|
||||||
let syncing = $state(false);
|
let syncing = $state(false);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@@ -37,16 +40,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadRecords() {
|
async function loadRecords() {
|
||||||
loading = true;
|
await dnsCache.refresh();
|
||||||
try {
|
const { error } = get(dnsCache);
|
||||||
const settings = await getSettings();
|
if (error) toasts.error(error || $t('dns.loadFailed'));
|
||||||
wildcardDns = settings.wildcard_dns ?? true;
|
|
||||||
records = await getDnsRecords();
|
|
||||||
} catch (err) {
|
|
||||||
toasts.error(err instanceof Error ? err.message : $t('dns.loadFailed'));
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSync() {
|
async function handleSync() {
|
||||||
@@ -66,7 +62,7 @@
|
|||||||
try {
|
try {
|
||||||
await deleteDnsRecord(fqdn);
|
await deleteDnsRecord(fqdn);
|
||||||
toasts.success($t('dns.recordDeleted', { fqdn }));
|
toasts.success($t('dns.recordDeleted', { fqdn }));
|
||||||
records = records.filter(r => r.fqdn !== fqdn);
|
await dnsCache.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('dns.deleteFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('dns.deleteFailed'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import * as api from '$lib/api';
|
|
||||||
import type { EventTrigger } from '$lib/api';
|
import type { EventTrigger } from '$lib/api';
|
||||||
|
import { eventTriggersCache } from '$lib/stores/caches';
|
||||||
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
let triggers = $state<EventTrigger[]>([]);
|
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
|
||||||
let loading = $state(true);
|
// list immediately instead of flashing the cold skeleton. See caches.ts.
|
||||||
let error = $state('');
|
const triggers = $derived<EventTrigger[]>($eventTriggersCache.value);
|
||||||
|
const loading = $derived($eventTriggersCache.loading);
|
||||||
|
const error = $derived($eventTriggersCache.error);
|
||||||
|
|
||||||
const enabledCount = $derived(triggers.filter((t) => t.enabled).length);
|
const enabledCount = $derived(triggers.filter((t) => t.enabled).length);
|
||||||
const disabledCount = $derived(triggers.length - enabledCount);
|
const disabledCount = $derived(triggers.length - enabledCount);
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
const load = () => eventTriggersCache.refresh();
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
triggers = await api.listEventTriggers();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load event triggers';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a short, comma-separated filter summary so the list table can
|
// Render a short, comma-separated filter summary so the list table can
|
||||||
// fit each trigger in a single row without a sub-table. The
|
// fit each trigger in a single row without a sub-table. The
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import type { EventTrigger, EventTriggerInput, NotificationTestResult } from '$lib/api';
|
import type { EventTrigger, EventTriggerInput, NotificationTestResult } from '$lib/api';
|
||||||
|
import { eventTriggerDetailCache } from '$lib/stores/caches';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
@@ -51,33 +52,57 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function seedForm(tr: EventTrigger): void {
|
||||||
|
name = tr.name;
|
||||||
|
filterSeverity = tr.filter_severity;
|
||||||
|
filterSource = tr.filter_source;
|
||||||
|
filterMessageRegex = tr.filter_message_regex;
|
||||||
|
actionTarget = tr.action_target;
|
||||||
|
// Server returns either "" (no secret) or a placeholder when one is
|
||||||
|
// configured. Keep the value so an unchanged echo round-trips as "no
|
||||||
|
// change."
|
||||||
|
actionSecret = tr.action_secret;
|
||||||
|
secretConfigured = tr.action_secret !== '';
|
||||||
|
enabled = tr.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyed warm-seed: on revisit, render the cached trigger instantly and seed
|
||||||
|
// the editable form ONCE per id (a background revalidation never re-seeds,
|
||||||
|
// so unsaved edits survive). `seededKey` is intentionally a plain (non-
|
||||||
|
// reactive) var so the $effect below depends only on `id`.
|
||||||
|
let seededKey: string | null = null;
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
if (id === null) {
|
if (id === null) {
|
||||||
error = 'Invalid trigger id';
|
error = 'Invalid trigger id';
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading = true;
|
const k = String(id);
|
||||||
error = '';
|
const cached = eventTriggerDetailCache.peek(k);
|
||||||
try {
|
if (cached.value) {
|
||||||
const tr = await api.getEventTrigger(id);
|
trigger = cached.value;
|
||||||
trigger = tr;
|
if (seededKey !== k) { seedForm(cached.value); seededKey = k; }
|
||||||
name = tr.name;
|
|
||||||
filterSeverity = tr.filter_severity;
|
|
||||||
filterSource = tr.filter_source;
|
|
||||||
filterMessageRegex = tr.filter_message_regex;
|
|
||||||
actionTarget = tr.action_target;
|
|
||||||
// Server returns either "" (no secret) or a placeholder
|
|
||||||
// when one is configured. Keep the value in state so an
|
|
||||||
// unchanged echo round-trips as "no change."
|
|
||||||
actionSecret = tr.action_secret;
|
|
||||||
secretConfigured = tr.action_secret !== '';
|
|
||||||
enabled = tr.enabled;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load trigger';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
} else {
|
||||||
|
loading = true;
|
||||||
}
|
}
|
||||||
|
error = '';
|
||||||
|
await eventTriggerDetailCache.refresh(k);
|
||||||
|
// Bail if the route id changed while we were fetching — a newer load()
|
||||||
|
// now owns the page state; applying this stale result would clobber it.
|
||||||
|
if (id === null || String(id) !== k) return;
|
||||||
|
const entry = eventTriggerDetailCache.peek(k);
|
||||||
|
if (entry.error && !cached.value) {
|
||||||
|
error = entry.error || 'Failed to load trigger';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.value) {
|
||||||
|
trigger = entry.value;
|
||||||
|
if (seededKey !== k) { seedForm(entry.value); seededKey = k; }
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e?: Event): Promise<void> {
|
async function save(e?: Event): Promise<void> {
|
||||||
@@ -99,6 +124,9 @@
|
|||||||
trigger = await api.updateEventTrigger(id, body);
|
trigger = await api.updateEventTrigger(id, body);
|
||||||
actionSecret = trigger.action_secret;
|
actionSecret = trigger.action_secret;
|
||||||
secretConfigured = trigger.action_secret !== '';
|
secretConfigured = trigger.action_secret !== '';
|
||||||
|
// Keep the cache warm-fresh for the next visit (doesn't touch the
|
||||||
|
// form — seededKey already matches this id).
|
||||||
|
eventTriggerDetailCache.set(String(id), trigger);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Save failed';
|
error = e instanceof Error ? e.message : 'Save failed';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { connectGlobalEvents, toEventLogEntry, type SSEConnection, type EventLogSSEPayload } from '$lib/sse';
|
import { connectGlobalEvents, toEventLogEntry, type SSEConnection, type EventLogSSEPayload } from '$lib/sse';
|
||||||
import type { EventLogEntry, EventLogStats } from '$lib/types';
|
import type { EventLogEntry, EventLogStats } from '$lib/types';
|
||||||
|
import { getEventsSnapshot, saveEventsSnapshot } from '$lib/stores/eventsSnapshot';
|
||||||
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
|
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
|
||||||
import EventLogFilter from '$lib/components/EventLogFilter.svelte';
|
import EventLogFilter from '$lib/components/EventLogFilter.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
@@ -18,9 +19,13 @@
|
|||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
let events = $state<EventLogEntry[]>([]);
|
// Warm-start from the last-known snapshot so switching back to this tab
|
||||||
let stats = $state<EventLogStats>({ info: 0, warn: 0, error: 0, total: 0 });
|
// paints the previous events immediately instead of a skeleton; a
|
||||||
let loading = $state(true);
|
// background refresh in onMount then brings it current. See eventsSnapshot.ts.
|
||||||
|
const seed = getEventsSnapshot();
|
||||||
|
let events = $state<EventLogEntry[]>(seed?.events ?? []);
|
||||||
|
let stats = $state<EventLogStats>(seed?.stats ?? { info: 0, warn: 0, error: 0, total: 0 });
|
||||||
|
let loading = $state((seed?.events.length ?? 0) === 0);
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
let hasMore = $state(true);
|
let hasMore = $state(true);
|
||||||
let newEventIds = $state<Set<number>>(new Set());
|
let newEventIds = $state<Set<number>>(new Set());
|
||||||
@@ -61,7 +66,10 @@
|
|||||||
const currentOffset = append ? offset : 0;
|
const currentOffset = append ? offset : 0;
|
||||||
if (append) {
|
if (append) {
|
||||||
loadingMore = true;
|
loadingMore = true;
|
||||||
} else {
|
} else if (events.length === 0) {
|
||||||
|
// Only show the skeleton on a true cold load. When we already have
|
||||||
|
// (seeded or previously loaded) events, refresh in place so the list
|
||||||
|
// never blanks out — this is what removes the tab-switch flicker.
|
||||||
loading = true;
|
loading = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +143,13 @@
|
|||||||
loadEvents();
|
loadEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror the current list + stats into the module snapshot so the next
|
||||||
|
// visit can warm-start. Cheap (stores references); keeps the snapshot fresh
|
||||||
|
// through loads, SSE pushes, and deletes without touching each mutation site.
|
||||||
|
$effect(() => {
|
||||||
|
saveEventsSnapshot({ events, stats });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Client-side text filter ──────────────────────────────────
|
// ── Client-side text filter ──────────────────────────────────
|
||||||
|
|
||||||
const filteredEvents = $derived(
|
const filteredEvents = $derived(
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import * as api from '$lib/api';
|
import type { LogScanRule } from '$lib/api';
|
||||||
import type { LogScanRule, LogScanStats } from '$lib/api';
|
import { logScanCache } from '$lib/stores/caches';
|
||||||
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
let rules = $state<LogScanRule[]>([]);
|
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
|
||||||
let loading = $state(true);
|
// list immediately instead of flashing the cold skeleton. See caches.ts.
|
||||||
let error = $state('');
|
// Scanner stats are loaded alongside the rule list (non-fatal) so the
|
||||||
|
// operator sees drop counters + compile errors next to the rules causing
|
||||||
|
// them. Named `scanStats` to avoid colliding with the `{#snippet stats()}`
|
||||||
|
// slot below that feeds the hero.
|
||||||
|
const rules = $derived<LogScanRule[]>($logScanCache.value.rules);
|
||||||
|
const scanStats = $derived($logScanCache.value.stats);
|
||||||
|
const loading = $derived($logScanCache.loading);
|
||||||
|
const error = $derived($logScanCache.error);
|
||||||
let filter = $state<'all' | 'global' | 'workload' | 'override'>('all');
|
let filter = $state<'all' | 'global' | 'workload' | 'override'>('all');
|
||||||
// Scanner stats are loaded alongside the rule list so the
|
|
||||||
// operator sees drop counters + compile errors next to the rules
|
|
||||||
// causing them. Failure to load is non-fatal — the rules table
|
|
||||||
// is the primary content. Named `scanStats` to avoid colliding
|
|
||||||
// with the `{#snippet stats()}` slot below that feeds the hero.
|
|
||||||
let scanStats = $state<LogScanStats | null>(null);
|
|
||||||
|
|
||||||
const globals = $derived(rules.filter((r) => r.workload_id === '' && r.overrides_id === 0));
|
const globals = $derived(rules.filter((r) => r.workload_id === '' && r.overrides_id === 0));
|
||||||
const workloadOnly = $derived(
|
const workloadOnly = $derived(
|
||||||
@@ -37,22 +38,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
const load = () => logScanCache.refresh();
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const [rs, st] = await Promise.all([
|
|
||||||
api.listLogScanRules(),
|
|
||||||
api.getLogScanStats().catch(() => null)
|
|
||||||
]);
|
|
||||||
rules = rs;
|
|
||||||
scanStats = st;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load log scan rules';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopeLabel(r: LogScanRule): string {
|
function scopeLabel(r: LogScanRule): string {
|
||||||
if (r.overrides_id !== 0) {
|
if (r.overrides_id !== 0) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import type { LogScanRule, LogScanRuleInput, LogScanTestResult } from '$lib/api';
|
import type { LogScanRule, LogScanRuleInput, LogScanTestResult } from '$lib/api';
|
||||||
|
import { logScanRuleDetailCache } from '$lib/stores/caches';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
@@ -49,41 +50,67 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
function seedForm(r: LogScanRule): void {
|
||||||
if (id === null) {
|
|
||||||
error = 'Invalid rule id';
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const r = await api.getLogScanRule(id);
|
|
||||||
rule = r;
|
|
||||||
name = r.name;
|
name = r.name;
|
||||||
pattern = r.pattern;
|
pattern = r.pattern;
|
||||||
severity = r.severity;
|
severity = r.severity;
|
||||||
streams = r.streams;
|
streams = r.streams;
|
||||||
cooldownSeconds = r.cooldown_seconds;
|
cooldownSeconds = r.cooldown_seconds;
|
||||||
enabled = r.enabled;
|
enabled = r.enabled;
|
||||||
// Best-effort: resolve the workload name for the scope
|
}
|
||||||
// label. Failure here doesn't block the rest of the page —
|
|
||||||
// scopeLabel falls back to the truncated id.
|
// Best-effort scope-label resolution (fire-and-forget); failure falls back
|
||||||
if (r.workload_id) {
|
// to the truncated id. Runs once per id alongside the form seed.
|
||||||
|
async function resolveScopeName(r: LogScanRule, k: string): Promise<void> {
|
||||||
|
if (!r.workload_id) {
|
||||||
|
scopedWorkloadName = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const w = await api.getWorkload(r.workload_id);
|
const w = await api.getWorkload(r.workload_id);
|
||||||
scopedWorkloadName = w.name;
|
// Ignore a late response if we've since navigated to another id.
|
||||||
|
if (String(id) === k) scopedWorkloadName = w.name;
|
||||||
} catch {
|
} catch {
|
||||||
scopedWorkloadName = '';
|
if (String(id) === k) scopedWorkloadName = '';
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
scopedWorkloadName = '';
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load rule';
|
// Keyed warm-seed: render the cached rule instantly on revisit and seed the
|
||||||
} finally {
|
// editable form ONCE per id (a background revalidation never re-seeds, so
|
||||||
|
// unsaved edits survive). Plain (non-reactive) so the $effect tracks only id.
|
||||||
|
let seededKey: string | null = null;
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (id === null) {
|
||||||
|
error = 'Invalid rule id';
|
||||||
loading = false;
|
loading = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const k = String(id);
|
||||||
|
const cached = logScanRuleDetailCache.peek(k);
|
||||||
|
if (cached.value) {
|
||||||
|
rule = cached.value;
|
||||||
|
if (seededKey !== k) { seedForm(cached.value); resolveScopeName(cached.value, k); seededKey = k; }
|
||||||
|
loading = false;
|
||||||
|
} else {
|
||||||
|
loading = true;
|
||||||
|
}
|
||||||
|
error = '';
|
||||||
|
await logScanRuleDetailCache.refresh(k);
|
||||||
|
// Bail if the route id changed while we were fetching — a newer load()
|
||||||
|
// now owns the page state; applying this stale result would clobber it.
|
||||||
|
if (id === null || String(id) !== k) return;
|
||||||
|
const entry = logScanRuleDetailCache.peek(k);
|
||||||
|
if (entry.error && !cached.value) {
|
||||||
|
error = entry.error || 'Failed to load rule';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.value) {
|
||||||
|
rule = entry.value;
|
||||||
|
if (seededKey !== k) { seedForm(entry.value); resolveScopeName(entry.value, k); seededKey = k; }
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e?: Event): Promise<void> {
|
async function save(e?: Event): Promise<void> {
|
||||||
@@ -101,6 +128,7 @@
|
|||||||
enabled
|
enabled
|
||||||
};
|
};
|
||||||
rule = await api.updateLogScanRule(id, body);
|
rule = await api.updateLogScanRule(id, body);
|
||||||
|
logScanRuleDetailCache.set(String(id), rule);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Save failed';
|
error = e instanceof Error ? e.message : 'Save failed';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import * as api from '$lib/api';
|
|
||||||
import type { MetricAlertRule } from '$lib/api';
|
import type { MetricAlertRule } from '$lib/api';
|
||||||
|
import { metricAlertRulesCache } from '$lib/stores/caches';
|
||||||
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
let rules = $state<MetricAlertRule[]>([]);
|
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
|
||||||
let loading = $state(true);
|
// list immediately instead of flashing the cold skeleton. See caches.ts.
|
||||||
let error = $state('');
|
const rules = $derived<MetricAlertRule[]>($metricAlertRulesCache.value);
|
||||||
|
const loading = $derived($metricAlertRulesCache.loading);
|
||||||
|
const error = $derived($metricAlertRulesCache.error);
|
||||||
let filter = $state<'all' | 'global' | 'workload'>('all');
|
let filter = $state<'all' | 'global' | 'workload'>('all');
|
||||||
|
|
||||||
const globals = $derived(rules.filter((r) => r.workload_id === ''));
|
const globals = $derived(rules.filter((r) => r.workload_id === ''));
|
||||||
@@ -26,17 +28,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
const load = () => metricAlertRulesCache.refresh();
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
rules = await api.listMetricAlertRules();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load metric alert rules';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopeLabel(r: MetricAlertRule): string {
|
function scopeLabel(r: MetricAlertRule): string {
|
||||||
if (r.workload_id !== '') {
|
if (r.workload_id !== '') {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import type { MetricAlertRule, MetricAlertRuleInput } from '$lib/api';
|
import type { MetricAlertRule, MetricAlertRuleInput } from '$lib/api';
|
||||||
|
import { metricAlertRuleDetailCache } from '$lib/stores/caches';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
@@ -51,17 +52,7 @@
|
|||||||
: $t('metricalert.form.thresholdHintPercent')
|
: $t('metricalert.form.thresholdHintPercent')
|
||||||
);
|
);
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
function seedForm(r: MetricAlertRule): void {
|
||||||
if (id === null) {
|
|
||||||
error = 'Invalid rule id';
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const r = await api.getMetricAlertRule(id);
|
|
||||||
rule = r;
|
|
||||||
name = r.name;
|
name = r.name;
|
||||||
metric = r.metric;
|
metric = r.metric;
|
||||||
comparator = r.comparator;
|
comparator = r.comparator;
|
||||||
@@ -69,24 +60,60 @@
|
|||||||
severity = r.severity;
|
severity = r.severity;
|
||||||
cooldownSeconds = r.cooldown_seconds;
|
cooldownSeconds = r.cooldown_seconds;
|
||||||
enabled = r.enabled;
|
enabled = r.enabled;
|
||||||
// Best-effort: resolve the workload name for the scope
|
}
|
||||||
// label. Failure here doesn't block the rest of the page —
|
|
||||||
// scopeLabel falls back to the truncated id.
|
// Best-effort scope-label resolution (fire-and-forget); failure falls back
|
||||||
if (r.workload_id) {
|
// to the truncated id. Runs once per id alongside the form seed.
|
||||||
|
async function resolveScopeName(r: MetricAlertRule, k: string): Promise<void> {
|
||||||
|
if (!r.workload_id) {
|
||||||
|
scopedWorkloadName = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const w = await api.getWorkload(r.workload_id);
|
const w = await api.getWorkload(r.workload_id);
|
||||||
scopedWorkloadName = w.name;
|
// Ignore a late response if we've since navigated to another id.
|
||||||
|
if (String(id) === k) scopedWorkloadName = w.name;
|
||||||
} catch {
|
} catch {
|
||||||
scopedWorkloadName = '';
|
if (String(id) === k) scopedWorkloadName = '';
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
scopedWorkloadName = '';
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load rule';
|
// Keyed warm-seed: render the cached rule instantly on revisit and seed the
|
||||||
} finally {
|
// editable form ONCE per id (a background revalidation never re-seeds, so
|
||||||
|
// unsaved edits survive). Plain (non-reactive) so the $effect tracks only id.
|
||||||
|
let seededKey: string | null = null;
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (id === null) {
|
||||||
|
error = 'Invalid rule id';
|
||||||
loading = false;
|
loading = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const k = String(id);
|
||||||
|
const cached = metricAlertRuleDetailCache.peek(k);
|
||||||
|
if (cached.value) {
|
||||||
|
rule = cached.value;
|
||||||
|
if (seededKey !== k) { seedForm(cached.value); resolveScopeName(cached.value, k); seededKey = k; }
|
||||||
|
loading = false;
|
||||||
|
} else {
|
||||||
|
loading = true;
|
||||||
|
}
|
||||||
|
error = '';
|
||||||
|
await metricAlertRuleDetailCache.refresh(k);
|
||||||
|
// Bail if the route id changed while we were fetching — a newer load()
|
||||||
|
// now owns the page state; applying this stale result would clobber it.
|
||||||
|
if (id === null || String(id) !== k) return;
|
||||||
|
const entry = metricAlertRuleDetailCache.peek(k);
|
||||||
|
if (entry.error && !cached.value) {
|
||||||
|
error = entry.error || 'Failed to load rule';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.value) {
|
||||||
|
rule = entry.value;
|
||||||
|
if (seededKey !== k) { seedForm(entry.value); resolveScopeName(entry.value, k); seededKey = k; }
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e?: Event): Promise<void> {
|
async function save(e?: Event): Promise<void> {
|
||||||
@@ -105,6 +132,7 @@
|
|||||||
enabled
|
enabled
|
||||||
};
|
};
|
||||||
rule = await api.updateMetricAlertRule(id, body);
|
rule = await api.updateMetricAlertRule(id, body);
|
||||||
|
metricAlertRuleDetailCache.set(String(id), rule);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Save failed';
|
error = e instanceof Error ? e.message : 'Save failed';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { listProxyRoutes } from '$lib/api';
|
import { get } from 'svelte/store';
|
||||||
import type { ProxyRoute, ProxyRouteSource } from '$lib/types';
|
import type { ProxyRoute, ProxyRouteSource } from '$lib/types';
|
||||||
|
import { proxyRoutesCache } from '$lib/stores/caches';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
@@ -10,8 +11,10 @@
|
|||||||
|
|
||||||
type SourceFilter = 'all' | ProxyRouteSource;
|
type SourceFilter = 'all' | ProxyRouteSource;
|
||||||
|
|
||||||
let routes = $state<ProxyRoute[]>([]);
|
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
|
||||||
let loading = $state(true);
|
// routes immediately instead of flashing the cold skeleton. See caches.ts.
|
||||||
|
const routes = $derived<ProxyRoute[]>($proxyRoutesCache.value);
|
||||||
|
const loading = $derived($proxyRoutesCache.loading);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let sourceFilter = $state<SourceFilter>('all');
|
let sourceFilter = $state<SourceFilter>('all');
|
||||||
|
|
||||||
@@ -66,14 +69,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadRoutes() {
|
async function loadRoutes() {
|
||||||
loading = true;
|
await proxyRoutesCache.refresh();
|
||||||
try {
|
// Cache stores the error rather than throwing; surface it as a toast to
|
||||||
routes = await listProxyRoutes();
|
// preserve the page's prior failure UX.
|
||||||
} catch (err) {
|
const { error } = get(proxyRoutesCache);
|
||||||
toasts.error(err instanceof Error ? err.message : $t('proxies.loadFailed'));
|
if (error) toasts.error(error || $t('proxies.loadFailed'));
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getSettings } from '$lib/api';
|
import { settingsCache } from '$lib/stores/caches';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import {
|
import {
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@@ -18,12 +18,14 @@
|
|||||||
interface Props { children: Snippet; }
|
interface Props { children: Snippet; }
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
let proxyProvider = $state('npm');
|
// Reactive to the shared settings cache: warm on revisit so the proxy-
|
||||||
|
// specific sub-nav items (NPM / Traefik) render correctly on first paint
|
||||||
|
// instead of defaulting to npm then snapping, and it updates live when the
|
||||||
|
// landing page saves a provider change.
|
||||||
|
const proxyProvider = $derived($settingsCache.value?.proxy_provider ?? 'npm');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
getSettings().then((s) => {
|
settingsCache.refresh();
|
||||||
proxyProvider = s.proxy_provider ?? 'npm';
|
|
||||||
}).catch(() => {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type NavGroup = 'main' | 'proxy' | 'system' | 'security';
|
type NavGroup = 'main' | 'proxy' | 'system' | 'security';
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
else — DNS, integrations, maintenance — lives on its own page now.
|
else — DNS, integrations, maintenance — lives on its own page now.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getSettings, updateSettings } from '$lib/api';
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { updateSettings } from '$lib/api';
|
||||||
|
import type { Settings } from '$lib/types';
|
||||||
|
import { settingsCache } from '$lib/stores/caches';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
@@ -77,10 +81,10 @@
|
|||||||
return Object.keys(next).length === 0;
|
return Object.keys(next).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
// Seed the form fields from a Settings object. Applied once on mount (from
|
||||||
loading = true;
|
// the warm cache if available, else from the network) — never re-applied
|
||||||
try {
|
// during a background refresh, so it can't clobber unsaved edits.
|
||||||
const s = await getSettings();
|
function seedForm(s: Settings) {
|
||||||
domain = s.domain ?? '';
|
domain = s.domain ?? '';
|
||||||
serverIp = s.server_ip ?? '';
|
serverIp = s.server_ip ?? '';
|
||||||
publicIp = s.public_ip ?? '';
|
publicIp = s.public_ip ?? '';
|
||||||
@@ -89,11 +93,26 @@
|
|||||||
pollingInterval = parseDurationToSeconds(s.polling_interval ?? '60');
|
pollingInterval = parseDurationToSeconds(s.polling_interval ?? '60');
|
||||||
baseVolumePath = s.base_volume_path ?? '';
|
baseVolumePath = s.base_volume_path ?? '';
|
||||||
proxyProvider = s.proxy_provider ?? 'npm';
|
proxyProvider = s.proxy_provider ?? 'npm';
|
||||||
} catch (err) {
|
}
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
|
||||||
} finally {
|
async function loadSettings() {
|
||||||
|
// Warm cache → seed immediately and skip the skeleton (kills the
|
||||||
|
// entry flicker). Cold → keep the skeleton until the fetch resolves.
|
||||||
|
const cached = get(settingsCache).value;
|
||||||
|
if (cached) {
|
||||||
|
seedForm(cached);
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
await settingsCache.refresh();
|
||||||
|
const fresh = get(settingsCache).value;
|
||||||
|
// Only seed from the fetch when nothing was shown yet (cold load), so a
|
||||||
|
// background refresh never overwrites edits the user may have started.
|
||||||
|
if (fresh && loading) seedForm(fresh);
|
||||||
|
loading = false;
|
||||||
|
const { error } = get(settingsCache);
|
||||||
|
if (error && !cached) {
|
||||||
|
toasts.error(error || $t('settingsGeneral.loadFailed'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -110,6 +129,11 @@
|
|||||||
base_volume_path: baseVolumePath.trim(),
|
base_volume_path: baseVolumePath.trim(),
|
||||||
proxy_provider: proxyProvider
|
proxy_provider: proxyProvider
|
||||||
});
|
});
|
||||||
|
// Refetch the canonical Settings into the shared cache so the
|
||||||
|
// sub-layout's proxy-nav and the next visit reflect the save.
|
||||||
|
// (PUT /api/settings returns {status:"updated"}, not the object —
|
||||||
|
// so we must refresh, not set() the response.)
|
||||||
|
await settingsCache.refresh();
|
||||||
toasts.success($t('settingsGeneral.saved'));
|
toasts.success($t('settingsGeneral.saved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||||
@@ -118,7 +142,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { loadSettings(); });
|
onMount(loadSettings);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
|
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
@@ -7,15 +8,14 @@
|
|||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import {
|
import {
|
||||||
getAuthSettings,
|
|
||||||
updateAuthSettings,
|
updateAuthSettings,
|
||||||
listUsers as apiListUsers,
|
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser as apiDeleteUser,
|
deleteUser as apiDeleteUser,
|
||||||
ApiError,
|
ApiError,
|
||||||
type AuthSettings,
|
type AuthSettings,
|
||||||
type AuthUser
|
type AuthUser
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
import { authSettingsCache, usersCache } from '$lib/stores/caches';
|
||||||
|
|
||||||
type User = AuthUser;
|
type User = AuthUser;
|
||||||
|
|
||||||
@@ -23,7 +23,9 @@
|
|||||||
let settings = $state<AuthSettings>({
|
let settings = $state<AuthSettings>({
|
||||||
auth_mode: 'local', oidc_client_id: '', oidc_client_secret: '', oidc_issuer_url: '', oidc_redirect_url: ''
|
auth_mode: 'local', oidc_client_id: '', oidc_client_secret: '', oidc_issuer_url: '', oidc_redirect_url: ''
|
||||||
});
|
});
|
||||||
let users = $state<User[]>([]);
|
// Users list is cache-backed (stale-while-revalidate); the auth-settings
|
||||||
|
// form is seeded once on mount from authSettingsCache.
|
||||||
|
const users = $derived<User[]>($usersCache.value);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let message = $state('');
|
let message = $state('');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -33,34 +35,32 @@
|
|||||||
let newEmail = $state('');
|
let newEmail = $state('');
|
||||||
let newRole = $state('viewer');
|
let newRole = $state('viewer');
|
||||||
|
|
||||||
|
// Refresh wrapper kept so the mutation handlers below read naturally.
|
||||||
|
const loadUsers = () => usersCache.refresh();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
// Warm-seed the auth-settings form + skip the skeleton when both caches
|
||||||
await Promise.all([loadSettings(), loadUsers()]);
|
// are already populated; otherwise keep the skeleton until the fetches
|
||||||
} finally {
|
// resolve. The form is seeded once — a background refresh never
|
||||||
|
// re-applies it, so unsaved edits are safe.
|
||||||
|
const authState = get(authSettingsCache);
|
||||||
|
if (authState.loaded && authState.value) settings = { ...authState.value };
|
||||||
|
if (authState.loaded && get(usersCache).loaded) loading = false;
|
||||||
|
await Promise.all([authSettingsCache.refresh(), usersCache.refresh()]);
|
||||||
|
const fresh = get(authSettingsCache).value;
|
||||||
|
if (fresh && loading) settings = { ...fresh };
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
const e = get(authSettingsCache).error || get(usersCache).error;
|
||||||
|
if (e && !authState.loaded) error = e;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
try {
|
|
||||||
settings = await getAuthSettings();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
error = err instanceof ApiError ? err.message : $t('settingsAuth.loadFailed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers() {
|
|
||||||
try {
|
|
||||||
users = (await apiListUsers()) ?? [];
|
|
||||||
} catch (err: unknown) {
|
|
||||||
error = err instanceof ApiError ? err.message : $t('settingsAuth.loadFailed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
saving = true; message = ''; error = '';
|
saving = true; message = ''; error = '';
|
||||||
try {
|
try {
|
||||||
await updateAuthSettings(settings);
|
await updateAuthSettings(settings);
|
||||||
|
// Keep the cache fresh for the next visit without re-seeding the
|
||||||
|
// current form (which already reflects the just-saved values).
|
||||||
|
void authSettingsCache.refresh();
|
||||||
message = $t('settingsAuth.saved');
|
message = $t('settingsAuth.saved');
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error = err instanceof ApiError ? err.message : $t('settingsAuth.saveFailed');
|
error = err instanceof ApiError ? err.message : $t('settingsAuth.saveFailed');
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getSettings, updateSettings, listBackups, triggerBackup, deleteBackup, restoreBackup, backupDownloadUrl } from '$lib/api';
|
import { onMount } from 'svelte';
|
||||||
import type { BackupInfo } from '$lib/types';
|
import { get } from 'svelte/store';
|
||||||
|
import { updateSettings, triggerBackup, deleteBackup, restoreBackup, backupDownloadUrl } from '$lib/api';
|
||||||
|
import type { BackupInfo, Settings } from '$lib/types';
|
||||||
|
import { settingsCache, backupsCache } from '$lib/stores/caches';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
@@ -14,39 +17,44 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let creatingBackup = $state(false);
|
let creatingBackup = $state(false);
|
||||||
let refreshing = $state(false);
|
|
||||||
|
|
||||||
let backupEnabled = $state(false);
|
let backupEnabled = $state(false);
|
||||||
let backupIntervalHours = $state('24');
|
let backupIntervalHours = $state('24');
|
||||||
let backupRetentionCount = $state('10');
|
let backupRetentionCount = $state('10');
|
||||||
let autoBackupBeforeDeploy = $state(false);
|
let autoBackupBeforeDeploy = $state(false);
|
||||||
let backups = $state<BackupInfo[]>([]);
|
// Backups list is cache-backed (stale-while-revalidate); the settings form
|
||||||
|
// fields above are seeded once on mount from the shared settingsCache.
|
||||||
|
const backups = $derived($backupsCache.value);
|
||||||
|
const refreshing = $derived($backupsCache.refreshing);
|
||||||
|
|
||||||
let confirmDeleteId = $state('');
|
let confirmDeleteId = $state('');
|
||||||
let confirmRestoreId = $state('');
|
let confirmRestoreId = $state('');
|
||||||
|
|
||||||
|
// Seed the settings-form fields once from the shared cache; never re-applied
|
||||||
|
// during a background refresh, so unsaved edits are safe.
|
||||||
|
function seedForm(s: Settings) {
|
||||||
|
backupEnabled = s.backup_enabled ?? false;
|
||||||
|
backupIntervalHours = String(s.backup_interval_hours ?? 24);
|
||||||
|
backupRetentionCount = String(s.backup_retention_count ?? 10);
|
||||||
|
autoBackupBeforeDeploy = s.auto_backup_before_deploy ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData(refresh = false) {
|
async function loadData(refresh = false) {
|
||||||
|
// Explicit "refresh" button only refetches the backups list.
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
refreshing = true;
|
await backupsCache.refresh();
|
||||||
} else {
|
return;
|
||||||
loading = true;
|
|
||||||
}
|
}
|
||||||
try {
|
// Warm caches → seed form + skip the skeleton; cold → keep skeleton.
|
||||||
const [settings, backupList] = await Promise.all([
|
const sCached = get(settingsCache).value;
|
||||||
getSettings(),
|
if (sCached) seedForm(sCached);
|
||||||
listBackups()
|
if (sCached && get(backupsCache).loaded) loading = false;
|
||||||
]);
|
await Promise.all([settingsCache.refresh(), backupsCache.refresh()]);
|
||||||
backupEnabled = settings.backup_enabled ?? false;
|
const fresh = get(settingsCache).value;
|
||||||
backupIntervalHours = String(settings.backup_interval_hours ?? 24);
|
if (fresh && loading) seedForm(fresh);
|
||||||
backupRetentionCount = String(settings.backup_retention_count ?? 10);
|
|
||||||
autoBackupBeforeDeploy = settings.auto_backup_before_deploy ?? false;
|
|
||||||
backups = backupList ?? [];
|
|
||||||
} catch (err) {
|
|
||||||
toasts.error(err instanceof Error ? err.message : 'Failed to load backup settings');
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
loading = false;
|
||||||
refreshing = false;
|
const err = get(settingsCache).error || get(backupsCache).error;
|
||||||
}
|
if (err && !sCached) toasts.error(err || 'Failed to load backup settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -58,6 +66,8 @@
|
|||||||
backup_retention_count: Math.max(1, parseInt(backupRetentionCount, 10) || 10),
|
backup_retention_count: Math.max(1, parseInt(backupRetentionCount, 10) || 10),
|
||||||
auto_backup_before_deploy: autoBackupBeforeDeploy
|
auto_backup_before_deploy: autoBackupBeforeDeploy
|
||||||
});
|
});
|
||||||
|
// PUT returns {status:"updated"}, not Settings — refetch the cache.
|
||||||
|
await settingsCache.refresh();
|
||||||
toasts.success($t('settingsBackup.saved'));
|
toasts.success($t('settingsBackup.saved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.saveFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.saveFailed'));
|
||||||
@@ -69,8 +79,8 @@
|
|||||||
async function handleBackupNow() {
|
async function handleBackupNow() {
|
||||||
creatingBackup = true;
|
creatingBackup = true;
|
||||||
try {
|
try {
|
||||||
const backup = await triggerBackup();
|
await triggerBackup();
|
||||||
backups = [backup, ...backups];
|
await backupsCache.refresh();
|
||||||
toasts.success($t('settingsBackup.backupCreated'));
|
toasts.success($t('settingsBackup.backupCreated'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.backupFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.backupFailed'));
|
||||||
@@ -84,7 +94,7 @@
|
|||||||
confirmDeleteId = '';
|
confirmDeleteId = '';
|
||||||
try {
|
try {
|
||||||
await deleteBackup(id);
|
await deleteBackup(id);
|
||||||
backups = backups.filter(b => b.id !== id);
|
await backupsCache.refresh();
|
||||||
toasts.success($t('settingsBackup.deleted'));
|
toasts.success($t('settingsBackup.deleted'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.deleteFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.deleteFailed'));
|
||||||
@@ -133,7 +143,7 @@
|
|||||||
return /Z|[+-]\d{2}:?\d{2}$/.test(dateStr) ? dateStr : dateStr + 'Z';
|
return /Z|[+-]\d{2}:?\d{2}$/.test(dateStr) ? dateStr : dateStr + 'Z';
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { loadData(); });
|
onMount(() => loadData());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -6,8 +6,11 @@
|
|||||||
connection" flow aren't buried under unrelated infra fields.
|
connection" flow aren't buried under unrelated infra fields.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getSettings, updateSettings, testDnsConnection, listDnsZones } from '$lib/api';
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { updateSettings, testDnsConnection, listDnsZones } from '$lib/api';
|
||||||
import type { EntityPickerItem, Settings } from '$lib/types';
|
import type { EntityPickerItem, Settings } from '$lib/types';
|
||||||
|
import { settingsCache } from '$lib/stores/caches';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
@@ -30,22 +33,28 @@
|
|||||||
let zoneName = $state('');
|
let zoneName = $state('');
|
||||||
let testingDns = $state(false);
|
let testingDns = $state(false);
|
||||||
|
|
||||||
async function loadSettings() {
|
// Seed once from the shared settings cache (warm → no skeleton on revisit);
|
||||||
loading = true;
|
// never re-applied during background refresh, so unsaved edits are safe.
|
||||||
try {
|
function seed(s: Settings) {
|
||||||
const s = await getSettings();
|
|
||||||
wildcardDns = s.wildcard_dns ?? true;
|
wildcardDns = s.wildcard_dns ?? true;
|
||||||
dnsProvider = s.dns_provider ?? '';
|
dnsProvider = s.dns_provider ?? '';
|
||||||
hasCloudflareApiToken = s.has_cloudflare_api_token ?? false;
|
hasCloudflareApiToken = s.has_cloudflare_api_token ?? false;
|
||||||
cloudflareZoneId = s.cloudflare_zone_id ?? '';
|
cloudflareZoneId = s.cloudflare_zone_id ?? '';
|
||||||
if (!wildcardDns && cloudflareZoneId) {
|
if (!wildcardDns && cloudflareZoneId) resolveZoneName();
|
||||||
resolveZoneName();
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
async function loadSettings() {
|
||||||
} finally {
|
const cached = get(settingsCache).value;
|
||||||
|
if (cached) {
|
||||||
|
seed(cached);
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
await settingsCache.refresh();
|
||||||
|
const fresh = get(settingsCache).value;
|
||||||
|
if (fresh && loading) seed(fresh);
|
||||||
|
loading = false;
|
||||||
|
const { error } = get(settingsCache);
|
||||||
|
if (error && !cached) toasts.error(error || $t('settingsGeneral.loadFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -58,6 +67,8 @@
|
|||||||
};
|
};
|
||||||
if (cloudflareApiToken) payload.cloudflare_api_token = cloudflareApiToken;
|
if (cloudflareApiToken) payload.cloudflare_api_token = cloudflareApiToken;
|
||||||
await updateSettings(payload);
|
await updateSettings(payload);
|
||||||
|
// PUT returns {status:"updated"}, not Settings — refetch the cache.
|
||||||
|
await settingsCache.refresh();
|
||||||
toasts.success($t('settingsGeneral.saved'));
|
toasts.success($t('settingsGeneral.saved'));
|
||||||
cloudflareApiToken = '';
|
cloudflareApiToken = '';
|
||||||
hasCloudflareApiToken = hasCloudflareApiToken || Boolean(payload.cloudflare_api_token);
|
hasCloudflareApiToken = hasCloudflareApiToken || Boolean(payload.cloudflare_api_token);
|
||||||
@@ -126,7 +137,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { loadSettings(); });
|
onMount(loadSettings);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -8,13 +8,16 @@
|
|||||||
detail pages — this page deliberately does not surface them.
|
detail pages — this page deliberately does not surface them.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import {
|
import {
|
||||||
getSettings, updateSettings,
|
updateSettings,
|
||||||
getSettingsNotificationSecret,
|
getSettingsNotificationSecret,
|
||||||
regenerateSettingsNotificationSecret,
|
regenerateSettingsNotificationSecret,
|
||||||
disableSettingsNotificationSigning,
|
disableSettingsNotificationSigning,
|
||||||
testSettingsNotification,
|
testSettingsNotification,
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
import { settingsCache } from '$lib/stores/caches';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
||||||
@@ -36,17 +39,24 @@
|
|||||||
try { new URL(value.trim()); return ''; } catch { return $t('validation.invalidUrl'); }
|
try { new URL(value.trim()); return ''; } catch { return $t('validation.invalidUrl'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed once from the shared settings cache (warm → no skeleton on revisit);
|
||||||
|
// never re-applied during background refresh, so unsaved edits are safe.
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
const cached = get(settingsCache).value;
|
||||||
try {
|
if (cached) {
|
||||||
const settings = await getSettings();
|
notificationUrl = cached.notification_url ?? '';
|
||||||
notificationUrl = settings.notification_url ?? '';
|
|
||||||
savedNotificationUrl = notificationUrl;
|
savedNotificationUrl = notificationUrl;
|
||||||
} catch (err) {
|
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
await settingsCache.refresh();
|
||||||
|
const fresh = get(settingsCache).value;
|
||||||
|
if (fresh && loading) {
|
||||||
|
notificationUrl = fresh.notification_url ?? '';
|
||||||
|
savedNotificationUrl = notificationUrl;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
const { error } = get(settingsCache);
|
||||||
|
if (error && !cached) toasts.error(error || $t('settingsGeneral.loadFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -56,6 +66,8 @@
|
|||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
await updateSettings({ notification_url: notificationUrl.trim() });
|
await updateSettings({ notification_url: notificationUrl.trim() });
|
||||||
|
// PUT returns {status:"updated"}, not Settings — refetch the cache.
|
||||||
|
await settingsCache.refresh();
|
||||||
savedNotificationUrl = notificationUrl.trim();
|
savedNotificationUrl = notificationUrl.trim();
|
||||||
toasts.success($t('settingsGeneral.saved'));
|
toasts.success($t('settingsGeneral.saved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -65,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { load(); });
|
onMount(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
never within casual miss-click distance of general form fields.
|
never within casual miss-click distance of general form fields.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getSettings, updateSettings, pruneImages, pruneBuildCache } from '$lib/api';
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { updateSettings, pruneImages, pruneBuildCache } from '$lib/api';
|
||||||
|
import type { Settings } from '$lib/types';
|
||||||
|
import { settingsCache } from '$lib/stores/caches';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
@@ -26,19 +30,28 @@
|
|||||||
let statsIntervalSeconds = $state('15');
|
let statsIntervalSeconds = $state('15');
|
||||||
let statsRetentionHours = $state('2');
|
let statsRetentionHours = $state('2');
|
||||||
|
|
||||||
async function load() {
|
// Seed form fields once from the shared settings cache (warm → no skeleton
|
||||||
loading = true;
|
// on revisit); never re-applied during background refresh, so unsaved edits
|
||||||
try {
|
// are safe. See caches.ts / settings landing page for the pattern.
|
||||||
const s = await getSettings();
|
function seed(s: Settings) {
|
||||||
staleThresholdDays = String(s.stale_threshold_days ?? 7);
|
staleThresholdDays = String(s.stale_threshold_days ?? 7);
|
||||||
imagePruneThresholdMb = String(s.image_prune_threshold_mb ?? 1024);
|
imagePruneThresholdMb = String(s.image_prune_threshold_mb ?? 1024);
|
||||||
statsIntervalSeconds = String(s.stats_interval_seconds ?? 15);
|
statsIntervalSeconds = String(s.stats_interval_seconds ?? 15);
|
||||||
statsRetentionHours = String(s.stats_retention_hours ?? 2);
|
statsRetentionHours = String(s.stats_retention_hours ?? 2);
|
||||||
} catch (err) {
|
}
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
|
||||||
} finally {
|
async function load() {
|
||||||
|
const cached = get(settingsCache).value;
|
||||||
|
if (cached) {
|
||||||
|
seed(cached);
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
await settingsCache.refresh();
|
||||||
|
const fresh = get(settingsCache).value;
|
||||||
|
if (fresh && loading) seed(fresh);
|
||||||
|
loading = false;
|
||||||
|
const { error } = get(settingsCache);
|
||||||
|
if (error && !cached) toasts.error(error || $t('settingsGeneral.loadFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -59,6 +72,8 @@
|
|||||||
stats_interval_seconds: interval,
|
stats_interval_seconds: interval,
|
||||||
stats_retention_hours: retention
|
stats_retention_hours: retention
|
||||||
});
|
});
|
||||||
|
// PUT returns {status:"updated"}, not Settings — refetch the cache.
|
||||||
|
await settingsCache.refresh();
|
||||||
toasts.success($t('settingsGeneral.saved'));
|
toasts.success($t('settingsGeneral.saved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||||
@@ -91,7 +106,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { load(); });
|
onMount(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getSettings, updateSettings, listNpmCertificates, listNpmAccessLists, testNpmConnection } from '$lib/api';
|
import { onMount } from 'svelte';
|
||||||
import type { EntityPickerItem } from '$lib/types';
|
import { get } from 'svelte/store';
|
||||||
|
import { updateSettings, listNpmCertificates, listNpmAccessLists, testNpmConnection } from '$lib/api';
|
||||||
|
import type { EntityPickerItem, Settings } from '$lib/types';
|
||||||
|
import { settingsCache } from '$lib/stores/caches';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||||
@@ -41,20 +44,32 @@
|
|||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
// Seed once from the shared settings cache (warm → no skeleton on revisit);
|
||||||
loading = true;
|
// never re-applied during background refresh, so unsaved edits are safe.
|
||||||
try {
|
function seed(s: Settings) {
|
||||||
const settings = await getSettings();
|
npmUrl = s.npm_url ?? '';
|
||||||
npmUrl = settings.npm_url ?? '';
|
npmEmail = s.npm_email ?? '';
|
||||||
npmEmail = settings.npm_email ?? '';
|
npmHasCredentials = !!(s.npm_url && s.npm_email);
|
||||||
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
|
|
||||||
npmPassword = '';
|
npmPassword = '';
|
||||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
sslCertificateId = s.ssl_certificate_id ?? 0;
|
||||||
npmRemote = settings.npm_remote ?? false;
|
npmRemote = s.npm_remote ?? false;
|
||||||
accessListId = settings.npm_access_list_id ?? 0;
|
accessListId = s.npm_access_list_id ?? 0;
|
||||||
if (sslCertificateId > 0) sslCertName = `Certificate #${sslCertificateId}`;
|
if (sslCertificateId > 0) sslCertName = `Certificate #${sslCertificateId}`;
|
||||||
if (accessListId > 0) accessListName = `Access List #${accessListId}`;
|
if (accessListId > 0) accessListName = `Access List #${accessListId}`;
|
||||||
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const cached = get(settingsCache).value;
|
||||||
|
if (cached) {
|
||||||
|
seed(cached);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
await settingsCache.refresh();
|
||||||
|
const fresh = get(settingsCache).value;
|
||||||
|
if (fresh && loading) seed(fresh);
|
||||||
|
loading = false;
|
||||||
|
const { error } = get(settingsCache);
|
||||||
|
if (error && !cached) toasts.error(error || $t('settingsCredentials.loadFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTestConnection() {
|
async function handleTestConnection() {
|
||||||
@@ -91,6 +106,7 @@
|
|||||||
const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim(), npm_remote: npmRemote };
|
const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim(), npm_remote: npmRemote };
|
||||||
if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
|
if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
|
||||||
await updateSettings(payload);
|
await updateSettings(payload);
|
||||||
|
await settingsCache.refresh();
|
||||||
npmHasCredentials = true;
|
npmHasCredentials = true;
|
||||||
editingNpm = false;
|
editingNpm = false;
|
||||||
npmPassword = '';
|
npmPassword = '';
|
||||||
@@ -117,7 +133,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveCertificate(id: number) {
|
async function saveCertificate(id: number) {
|
||||||
try { await updateSettings({ ssl_certificate_id: id }); toasts.success($t('settingsCredentials.saved')); }
|
try { await updateSettings({ ssl_certificate_id: id }); await settingsCache.refresh(); toasts.success($t('settingsCredentials.saved')); }
|
||||||
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); }
|
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +164,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveAccessList(id: number) {
|
async function saveAccessList(id: number) {
|
||||||
try { await updateSettings({ npm_access_list_id: id }); toasts.success($t('settingsCredentials.saved')); }
|
try { await updateSettings({ npm_access_list_id: id }); await settingsCache.refresh(); toasts.success($t('settingsCredentials.saved')); }
|
||||||
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); }
|
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +199,7 @@
|
|||||||
async function handleNpmRemoteChange() {
|
async function handleNpmRemoteChange() {
|
||||||
try {
|
try {
|
||||||
await updateSettings({ npm_remote: npmRemote });
|
await updateSettings({ npm_remote: npmRemote });
|
||||||
|
await settingsCache.refresh();
|
||||||
toasts.success($t('settingsCredentials.saved'));
|
toasts.success($t('settingsCredentials.saved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed'));
|
||||||
@@ -193,7 +210,7 @@
|
|||||||
await loadData();
|
await loadData();
|
||||||
await Promise.all([resolveCertName(), resolveAccessListName()]);
|
await Promise.all([resolveCertName(), resolveAccessListName()]);
|
||||||
}
|
}
|
||||||
$effect(() => { init(); });
|
onMount(init);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { listRegistries, createRegistry, updateRegistry, deleteRegistry, testRegistry } from '$lib/api';
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { createRegistry, updateRegistry, deleteRegistry, testRegistry } from '$lib/api';
|
||||||
import type { Registry } from '$lib/types';
|
import type { Registry } from '$lib/types';
|
||||||
|
import { registriesCache } from '$lib/stores/caches';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
@@ -9,8 +12,10 @@
|
|||||||
import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons';
|
import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
let registries = $state<Registry[]>([]);
|
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
|
||||||
let loading = $state(true);
|
// registry list immediately instead of flashing the cold skeleton.
|
||||||
|
const registries = $derived($registriesCache.value);
|
||||||
|
const loading = $derived($registriesCache.loading);
|
||||||
|
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
@@ -39,12 +44,11 @@
|
|||||||
function startEdit(registry: Registry) { editingId = registry.id; formName = registry.name; formUrl = registry.url; formType = registry.type; formToken = ''; formOwner = registry.owner ?? ''; showForm = true; errors = {}; }
|
function startEdit(registry: Registry) { editingId = registry.id; formName = registry.name; formUrl = registry.url; formType = registry.type; formToken = ''; formOwner = registry.owner ?? ''; showForm = true; errors = {}; }
|
||||||
|
|
||||||
async function loadRegistryList() {
|
async function loadRegistryList() {
|
||||||
loading = true;
|
await registriesCache.refresh();
|
||||||
try {
|
const { error } = get(registriesCache);
|
||||||
registries = await listRegistries();
|
if (error) { toasts.error(error || $t('settingsRegistries.loadFailed')); return; }
|
||||||
// Check health of all registries in the background.
|
// Check health of all registries in the background.
|
||||||
checkAllHealth();
|
checkAllHealth();
|
||||||
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.loadFailed')); } finally { loading = false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -78,7 +82,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAllHealth() {
|
async function checkAllHealth() {
|
||||||
const checks = registries.map(async (reg) => {
|
const checks = get(registriesCache).value.map(async (reg) => {
|
||||||
healthStatus[reg.id] = 'checking';
|
healthStatus[reg.id] = 'checking';
|
||||||
try {
|
try {
|
||||||
await testRegistry(reg.id);
|
await testRegistry(reg.id);
|
||||||
@@ -90,7 +94,7 @@
|
|||||||
await Promise.allSettled(checks);
|
await Promise.allSettled(checks);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { loadRegistryList(); });
|
onMount(loadRegistryList);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getSettings, updateSettings } from '$lib/api';
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { updateSettings } from '$lib/api';
|
||||||
|
import type { Settings } from '$lib/types';
|
||||||
|
import { settingsCache } from '$lib/stores/caches';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
@@ -14,17 +18,28 @@
|
|||||||
let traefikNetwork = $state('');
|
let traefikNetwork = $state('');
|
||||||
let traefikApiUrl = $state('');
|
let traefikApiUrl = $state('');
|
||||||
|
|
||||||
|
// Seed form fields once from the shared settings cache (warm → no skeleton
|
||||||
|
// on revisit); never re-applied during background refresh, so unsaved edits
|
||||||
|
// are safe. See caches.ts / settings landing page for the pattern.
|
||||||
|
function seed(s: Settings) {
|
||||||
|
traefikEntrypoint = s.traefik_entrypoint ?? 'websecure';
|
||||||
|
traefikCertResolver = s.traefik_cert_resolver ?? 'letsencrypt';
|
||||||
|
traefikNetwork = s.traefik_network ?? '';
|
||||||
|
traefikApiUrl = s.traefik_api_url ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading = true;
|
const cached = get(settingsCache).value;
|
||||||
try {
|
if (cached) {
|
||||||
const settings = await getSettings();
|
seed(cached);
|
||||||
traefikEntrypoint = settings.traefik_entrypoint ?? 'websecure';
|
loading = false;
|
||||||
traefikCertResolver = settings.traefik_cert_resolver ?? 'letsencrypt';
|
}
|
||||||
traefikNetwork = settings.traefik_network ?? '';
|
await settingsCache.refresh();
|
||||||
traefikApiUrl = settings.traefik_api_url ?? '';
|
const fresh = get(settingsCache).value;
|
||||||
} catch (err) {
|
if (fresh && loading) seed(fresh);
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
loading = false;
|
||||||
} finally { loading = false; }
|
const { error } = get(settingsCache);
|
||||||
|
if (error && !cached) toasts.error(error || $t('settingsGeneral.loadFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -36,13 +51,15 @@
|
|||||||
traefik_network: traefikNetwork.trim(),
|
traefik_network: traefikNetwork.trim(),
|
||||||
traefik_api_url: traefikApiUrl.trim()
|
traefik_api_url: traefikApiUrl.trim()
|
||||||
});
|
});
|
||||||
|
// PUT returns {status:"updated"}, not Settings — refetch the cache.
|
||||||
|
await settingsCache.refresh();
|
||||||
toasts.success($t('settingsGeneral.saved'));
|
toasts.success($t('settingsGeneral.saved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||||
} finally { saving = false; }
|
} finally { saving = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { loadData(); });
|
onMount(loadData);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import * as api from '$lib/api';
|
|
||||||
import type { SharedSecret } from '$lib/api';
|
import type { SharedSecret } from '$lib/api';
|
||||||
import type { App } from '$lib/types';
|
import { sharedSecretsCache } from '$lib/stores/caches';
|
||||||
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
let secrets = $state<SharedSecret[]>([]);
|
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
|
||||||
let apps = $state<App[]>([]);
|
// list immediately instead of flashing the cold skeleton. Apps are loaded
|
||||||
let loading = $state(true);
|
// alongside secrets (non-fatal) to resolve app-scoped row names. See caches.ts.
|
||||||
let error = $state('');
|
const secrets = $derived<SharedSecret[]>($sharedSecretsCache.value.secrets);
|
||||||
|
const apps = $derived($sharedSecretsCache.value.apps);
|
||||||
|
const loading = $derived($sharedSecretsCache.loading);
|
||||||
|
const error = $derived($sharedSecretsCache.error);
|
||||||
let filter = $state<'all' | 'global' | 'app'>('all');
|
let filter = $state<'all' | 'global' | 'app'>('all');
|
||||||
|
|
||||||
const globals = $derived(secrets.filter((s) => s.scope === 'global'));
|
const globals = $derived(secrets.filter((s) => s.scope === 'global'));
|
||||||
@@ -37,25 +39,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
const load = () => sharedSecretsCache.refresh();
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
// Load apps alongside secrets so app-scoped rows resolve their
|
|
||||||
// name. App load failure is non-fatal — the row falls back to
|
|
||||||
// the truncated id.
|
|
||||||
const [s, a] = await Promise.all([
|
|
||||||
api.listSharedSecrets(),
|
|
||||||
api.listApps().catch(() => [] as App[])
|
|
||||||
]);
|
|
||||||
secrets = s;
|
|
||||||
apps = a;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load shared secrets';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopeLabel(s: SharedSecret): string {
|
function scopeLabel(s: SharedSecret): string {
|
||||||
if (s.scope === 'app') {
|
if (s.scope === 'app') {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import type { SharedSecret, SharedSecretInput } from '$lib/api';
|
import type { SharedSecret, SharedSecretInput } from '$lib/api';
|
||||||
import type { EntityPickerItem, App } from '$lib/types';
|
import type { EntityPickerItem, App } from '$lib/types';
|
||||||
|
import { sharedSecretDetailCache } from '$lib/stores/caches';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||||
@@ -79,22 +80,7 @@
|
|||||||
appID = '';
|
appID = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
function seedForm(s: SharedSecret): void {
|
||||||
if (id === '') {
|
|
||||||
error = 'Invalid secret id';
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
// Load apps alongside the secret so the scope chip + picker
|
|
||||||
// resolve names. App load failure is non-fatal.
|
|
||||||
const [s, a] = await Promise.all([
|
|
||||||
api.getSharedSecret(id),
|
|
||||||
api.listApps().catch(() => [] as App[])
|
|
||||||
]);
|
|
||||||
secret = s;
|
|
||||||
name = s.name;
|
name = s.name;
|
||||||
// value intentionally NOT populated — write-only.
|
// value intentionally NOT populated — write-only.
|
||||||
value = '';
|
value = '';
|
||||||
@@ -104,12 +90,48 @@
|
|||||||
appID = s.app_id;
|
appID = s.app_id;
|
||||||
description = s.description;
|
description = s.description;
|
||||||
enabled = s.enabled;
|
enabled = s.enabled;
|
||||||
apps = a;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load secret';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyed warm-seed: render the cached secret metadata instantly on revisit
|
||||||
|
// and seed the editable form ONCE per id (a background revalidation never
|
||||||
|
// re-seeds, so unsaved edits survive). The cached object carries no
|
||||||
|
// plaintext value (the API returns only `has_value`). Plain (non-reactive)
|
||||||
|
// guard. Apps (for the picker) are best-effort and loaded each visit.
|
||||||
|
let seededKey: string | null = null;
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (id === '') {
|
||||||
|
error = 'Invalid secret id';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const k = id;
|
||||||
|
const cached = sharedSecretDetailCache.peek(k);
|
||||||
|
if (cached.value) {
|
||||||
|
secret = cached.value;
|
||||||
|
if (seededKey !== k) { seedForm(cached.value); seededKey = k; }
|
||||||
|
loading = false;
|
||||||
|
} else {
|
||||||
|
loading = true;
|
||||||
|
}
|
||||||
|
error = '';
|
||||||
|
const appsPromise = api.listApps().catch(() => [] as App[]);
|
||||||
|
await sharedSecretDetailCache.refresh(k);
|
||||||
|
apps = await appsPromise;
|
||||||
|
// Bail if the route id changed while we were fetching — a newer load()
|
||||||
|
// now owns the page state; applying this stale result would clobber it.
|
||||||
|
if (id !== k) return;
|
||||||
|
const entry = sharedSecretDetailCache.peek(k);
|
||||||
|
if (entry.error && !cached.value) {
|
||||||
|
error = entry.error || 'Failed to load secret';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.value) {
|
||||||
|
secret = entry.value;
|
||||||
|
if (seededKey !== k) { seedForm(entry.value); seededKey = k; }
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e?: Event): Promise<void> {
|
async function save(e?: Event): Promise<void> {
|
||||||
@@ -130,6 +152,7 @@
|
|||||||
// include it only when the operator typed a new one (rotate).
|
// include it only when the operator typed a new one (rotate).
|
||||||
if (value !== '') body.value = value;
|
if (value !== '') body.value = value;
|
||||||
secret = await api.updateSharedSecret(id, body);
|
secret = await api.updateSharedSecret(id, body);
|
||||||
|
sharedSecretDetailCache.set(id, secret);
|
||||||
// Re-baseline after a successful save: the new encrypted flag
|
// Re-baseline after a successful save: the new encrypted flag
|
||||||
// is now the loaded state, and the value field clears so a
|
// is now the loaded state, and the value field clears so a
|
||||||
// stale rotation can't be re-submitted.
|
// stale rotation can't be re-submitted.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import * as api from '$lib/api';
|
|
||||||
import type { RedeployTrigger } from '$lib/api';
|
import type { RedeployTrigger } from '$lib/api';
|
||||||
|
import { triggersCache } from '$lib/stores/caches';
|
||||||
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
@@ -13,9 +13,11 @@
|
|||||||
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule', 'webhook', 'logscan'] as const;
|
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule', 'webhook', 'logscan'] as const;
|
||||||
type KnownKind = (typeof KNOWN_KINDS)[number];
|
type KnownKind = (typeof KNOWN_KINDS)[number];
|
||||||
|
|
||||||
let triggers = $state<RedeployTrigger[]>([]);
|
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
|
||||||
let loading = $state(true);
|
// list immediately instead of flashing the cold skeleton. See caches.ts.
|
||||||
let error = $state('');
|
const triggers = $derived<RedeployTrigger[]>($triggersCache.value);
|
||||||
|
const loading = $derived($triggersCache.loading);
|
||||||
|
const error = $derived($triggersCache.error);
|
||||||
let kindFilter = $state<'all' | KnownKind | string>('all');
|
let kindFilter = $state<'all' | KnownKind | string>('all');
|
||||||
|
|
||||||
const filtered = $derived(
|
const filtered = $derived(
|
||||||
@@ -69,17 +71,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
const load = () => triggersCache.refresh();
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
triggers = await api.listTriggers();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load triggers';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import type {
|
import type {
|
||||||
RedeployTrigger,
|
RedeployTrigger,
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
TriggerInput,
|
TriggerInput,
|
||||||
TriggerWebhook
|
TriggerWebhook
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
import { triggerDetailCache } from '$lib/stores/caches';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
@@ -37,10 +39,16 @@
|
|||||||
return d.toLocaleString();
|
return d.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
let trigger = $state<RedeployTrigger | null>(null);
|
// Warm-seed the trigger from the per-id cache so the hero + config form
|
||||||
|
// render instantly on revisit; `loading` clears as soon as the trigger is
|
||||||
|
// ready. The webhook/bindings panels gate on `detailsLoaded` instead, since
|
||||||
|
// the webhook carries a rotated secret and is never cached.
|
||||||
|
const _seed = triggerDetailCache.peek(get(page).params.id ?? '').value;
|
||||||
|
let trigger = $state<RedeployTrigger | null>(_seed);
|
||||||
let webhook = $state<TriggerWebhook | null>(null);
|
let webhook = $state<TriggerWebhook | null>(null);
|
||||||
let bindings = $state<TriggerBinding[]>([]);
|
let bindings = $state<TriggerBinding[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(_seed === null);
|
||||||
|
let detailsLoaded = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
@@ -60,12 +68,11 @@
|
|||||||
// kind tag when showKindPicker=false.
|
// kind tag when showKindPicker=false.
|
||||||
let formState = $state(createTriggerKindFormState());
|
let formState = $state(createTriggerKindFormState());
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
// Seed the editable form ONCE per id (plain, non-reactive guard so the load
|
||||||
loading = true;
|
// effect tracks only `id`). On a warm fresh mount, seed from the cached
|
||||||
error = '';
|
// trigger right away; load() handles cold + reused-component (A→B) nav.
|
||||||
try {
|
let seededKey: string | null = null;
|
||||||
const tr = await api.getTrigger(id);
|
function seedFormFrom(tr: RedeployTrigger): void {
|
||||||
trigger = tr;
|
|
||||||
seedTriggerKindFormState(
|
seedTriggerKindFormState(
|
||||||
formState,
|
formState,
|
||||||
tr.kind,
|
tr.kind,
|
||||||
@@ -74,21 +81,64 @@
|
|||||||
tr.webhook_enabled,
|
tr.webhook_enabled,
|
||||||
tr.webhook_require_signature
|
tr.webhook_require_signature
|
||||||
);
|
);
|
||||||
// Fetch the webhook info only when ingress is enabled —
|
|
||||||
// otherwise the secret/url panel stays in the disabled
|
|
||||||
// state. Bindings always load.
|
|
||||||
const tasks: Array<Promise<unknown>> = [
|
|
||||||
api.listBindingsForTrigger(id).then((b) => (bindings = b))
|
|
||||||
];
|
|
||||||
if (tr.webhook_enabled) {
|
|
||||||
tasks.push(api.getTriggerWebhook(id).then((w) => (webhook = w)));
|
|
||||||
} else {
|
|
||||||
webhook = null;
|
|
||||||
}
|
}
|
||||||
await Promise.all(tasks);
|
if (_seed) {
|
||||||
|
seedFormFrom(_seed);
|
||||||
|
seededKey = get(page).params.id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monotonic load token — each invocation claims the latest seq, so an
|
||||||
|
// earlier in-flight load (even for the same id on A→B→A nav, where the
|
||||||
|
// id-only guard can't tell two same-id loads apart) is stale and cannot
|
||||||
|
// write trigger / bindings / webhook / detailsLoaded.
|
||||||
|
let loadSeq = 0;
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
const k = id;
|
||||||
|
const my = ++loadSeq;
|
||||||
|
// New id: its secondary panels (bindings/webhook) aren't loaded yet.
|
||||||
|
detailsLoaded = false;
|
||||||
|
const cached = triggerDetailCache.peek(k);
|
||||||
|
if (cached.value) {
|
||||||
|
trigger = cached.value;
|
||||||
|
if (seededKey !== k) {
|
||||||
|
seedFormFrom(cached.value);
|
||||||
|
seededKey = k;
|
||||||
|
}
|
||||||
|
loading = false; // warm: render the hero + config form immediately
|
||||||
|
} else {
|
||||||
|
loading = true;
|
||||||
|
}
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const tr = await api.getTrigger(id);
|
||||||
|
if (my !== loadSeq) return; // a newer load owns the state now
|
||||||
|
trigger = tr;
|
||||||
|
triggerDetailCache.set(k, tr);
|
||||||
|
if (seededKey !== k) {
|
||||||
|
seedFormFrom(tr);
|
||||||
|
seededKey = k;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
// Secondary panels. The webhook carries the rotated secret, so it is
|
||||||
|
// fetched fresh every time and NEVER cached. Reset both first so a
|
||||||
|
// reused-component warm nav (A→B) can't show A's data under B.
|
||||||
|
webhook = null;
|
||||||
|
bindings = [];
|
||||||
|
const [b, w] = await Promise.all([
|
||||||
|
api.listBindingsForTrigger(id),
|
||||||
|
tr.webhook_enabled ? api.getTriggerWebhook(id) : Promise.resolve(null)
|
||||||
|
]);
|
||||||
|
if (my !== loadSeq) return;
|
||||||
|
bindings = b;
|
||||||
|
webhook = w;
|
||||||
|
detailsLoaded = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (my !== loadSeq) return;
|
||||||
|
// Clear so a 404 (e.g. a deleted trigger revisited from cache) shows
|
||||||
|
// the error state instead of a stale, interactive phantom.
|
||||||
|
trigger = null;
|
||||||
error = e instanceof Error ? e.message : 'Failed to load trigger';
|
error = e instanceof Error ? e.message : 'Failed to load trigger';
|
||||||
} finally {
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +148,9 @@
|
|||||||
async function save(e?: Event): Promise<void> {
|
async function save(e?: Event): Promise<void> {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (!trigger || saving || !canSave) return;
|
if (!trigger || saving || !canSave) return;
|
||||||
|
// Pin the id for the round-trip so a mid-save nav can't write another
|
||||||
|
// trigger's state or poison its cache entry (the component is reused).
|
||||||
|
const k = id;
|
||||||
saving = true;
|
saving = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
@@ -110,17 +163,22 @@
|
|||||||
...buildTriggerInput(formState),
|
...buildTriggerInput(formState),
|
||||||
kind: trigger.kind
|
kind: trigger.kind
|
||||||
};
|
};
|
||||||
const updated = await api.updateTrigger(id, body);
|
const updated = await api.updateTrigger(k, body);
|
||||||
|
if (id !== k) return;
|
||||||
trigger = updated;
|
trigger = updated;
|
||||||
|
triggerDetailCache.set(k, updated);
|
||||||
// Webhook info comes/goes with the toggle. Keep state in
|
// Webhook info comes/goes with the toggle. Keep state in
|
||||||
// sync so the panel doesn't show stale secrets after
|
// sync so the panel doesn't show stale secrets after
|
||||||
// turning ingress off-then-on.
|
// turning ingress off-then-on.
|
||||||
if (updated.webhook_enabled) {
|
if (updated.webhook_enabled) {
|
||||||
webhook = await api.getTriggerWebhook(id);
|
const w = await api.getTriggerWebhook(k);
|
||||||
|
if (id !== k) return;
|
||||||
|
webhook = w;
|
||||||
} else {
|
} else {
|
||||||
webhook = null;
|
webhook = null;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
error = e instanceof Error ? e.message : 'Save failed';
|
error = e instanceof Error ? e.message : 'Save failed';
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
@@ -132,6 +190,9 @@
|
|||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
await api.deleteTrigger(id);
|
await api.deleteTrigger(id);
|
||||||
|
// Drop the cached entry so a back-nav to this deleted id doesn't
|
||||||
|
// warm-seed a phantom detail page.
|
||||||
|
triggerDetailCache.remove(id);
|
||||||
goto('/triggers');
|
goto('/triggers');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Delete failed';
|
error = e instanceof Error ? e.message : 'Delete failed';
|
||||||
@@ -164,15 +225,22 @@
|
|||||||
|
|
||||||
async function doFireNow(): Promise<void> {
|
async function doFireNow(): Promise<void> {
|
||||||
if (!trigger) return;
|
if (!trigger) return;
|
||||||
|
const k = id;
|
||||||
|
const tid = trigger.id;
|
||||||
firing = true;
|
firing = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await api.fireTriggerNow(trigger.id);
|
const res = await api.fireTriggerNow(tid);
|
||||||
|
if (id !== k) return;
|
||||||
fireResult = { deployed: res.deployed, errored: res.errored, bindings: res.bindings };
|
fireResult = { deployed: res.deployed, errored: res.errored, bindings: res.bindings };
|
||||||
scheduleFireFlashClear();
|
scheduleFireFlashClear();
|
||||||
// Refresh the trigger so the "last fired" row reflects the new ts.
|
// Refresh the trigger so the "last fired" row reflects the new ts.
|
||||||
trigger = await api.getTrigger(trigger.id);
|
const fresh = await api.getTrigger(tid);
|
||||||
|
if (id !== k) return;
|
||||||
|
trigger = fresh;
|
||||||
|
triggerDetailCache.set(k, fresh);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
error = e instanceof Error ? e.message : 'Fire failed';
|
error = e instanceof Error ? e.message : 'Fire failed';
|
||||||
} finally {
|
} finally {
|
||||||
firing = false;
|
firing = false;
|
||||||
@@ -181,10 +249,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doRotate(): Promise<void> {
|
async function doRotate(): Promise<void> {
|
||||||
|
const k = id;
|
||||||
rotating = true;
|
rotating = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await api.regenerateTriggerWebhook(id);
|
const res = await api.regenerateTriggerWebhook(k);
|
||||||
|
// Don't surface this trigger's new secret if we've navigated to
|
||||||
|
// another trigger while the rotate was in flight.
|
||||||
|
if (id !== k) return;
|
||||||
// regenerate returns the new url+secret but no signing
|
// regenerate returns the new url+secret but no signing
|
||||||
// flag — preserve the current toggle state.
|
// flag — preserve the current toggle state.
|
||||||
webhook = {
|
webhook = {
|
||||||
@@ -194,6 +266,7 @@
|
|||||||
webhook?.webhook_require_signature ?? formState.webhookRequireSig
|
webhook?.webhook_require_signature ?? formState.webhookRequireSig
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
error = e instanceof Error ? e.message : 'Rotate failed';
|
error = e instanceof Error ? e.message : 'Rotate failed';
|
||||||
} finally {
|
} finally {
|
||||||
rotating = false;
|
rotating = false;
|
||||||
@@ -202,10 +275,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleBinding(b: TriggerBinding, next: boolean): Promise<void> {
|
async function toggleBinding(b: TriggerBinding, next: boolean): Promise<void> {
|
||||||
|
const k = id;
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateBinding(b.id, { enabled: next });
|
const updated = await api.updateBinding(b.id, { enabled: next });
|
||||||
|
if (id !== k) return;
|
||||||
bindings = bindings.map((x) => (x.id === b.id ? { ...x, enabled: updated.enabled } : x));
|
bindings = bindings.map((x) => (x.id === b.id ? { ...x, enabled: updated.enabled } : x));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
error = e instanceof Error ? e.message : 'Update failed';
|
error = e instanceof Error ? e.message : 'Update failed';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,14 +289,18 @@
|
|||||||
async function doUnbind(): Promise<void> {
|
async function doUnbind(): Promise<void> {
|
||||||
if (!confirmUnbindId) return;
|
if (!confirmUnbindId) return;
|
||||||
const bid = confirmUnbindId;
|
const bid = confirmUnbindId;
|
||||||
|
const k = id;
|
||||||
try {
|
try {
|
||||||
await api.deleteBinding(bid);
|
await api.deleteBinding(bid);
|
||||||
|
if (id !== k) return;
|
||||||
bindings = bindings.filter((b) => b.id !== bid);
|
bindings = bindings.filter((b) => b.id !== bid);
|
||||||
// Reflect the new binding count in the hero.
|
// Reflect the new binding count in the hero.
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
trigger = { ...trigger, binding_count: Math.max(0, trigger.binding_count - 1) };
|
trigger = { ...trigger, binding_count: Math.max(0, trigger.binding_count - 1) };
|
||||||
|
triggerDetailCache.set(k, trigger);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (id !== k) return;
|
||||||
error = e instanceof Error ? e.message : 'Unbind failed';
|
error = e instanceof Error ? e.message : 'Unbind failed';
|
||||||
} finally {
|
} finally {
|
||||||
confirmUnbindId = null;
|
confirmUnbindId = null;
|
||||||
@@ -270,7 +350,7 @@
|
|||||||
<title>{trigger?.name ?? $t('redeployTriggers.titleSingular')} · Tinyforge</title>
|
<title>{trigger?.name ?? $t('redeployTriggers.titleSingular')} · Tinyforge</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="forge" aria-busy={loading}>
|
<div class="forge" aria-busy={loading || !detailsLoaded}>
|
||||||
{#snippet stats()}
|
{#snippet stats()}
|
||||||
<div>
|
<div>
|
||||||
<dt>KIND</dt>
|
<dt>KIND</dt>
|
||||||
@@ -390,7 +470,9 @@
|
|||||||
<span class="panel-sub">{$t('redeployTriggers.detail.webhookSub')}</span>
|
<span class="panel-sub">{$t('redeployTriggers.detail.webhookSub')}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if webhook && trigger.webhook_enabled}
|
{#if !detailsLoaded}
|
||||||
|
<div class="skeleton-rows" aria-busy="true"><div class="skeleton-row"></div></div>
|
||||||
|
{:else if webhook && trigger.webhook_enabled}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="sub-label">{$t('redeployTriggers.detail.webhookUrlLabel')}</span>
|
<span class="sub-label">{$t('redeployTriggers.detail.webhookUrlLabel')}</span>
|
||||||
<div class="url-box">
|
<div class="url-box">
|
||||||
@@ -460,7 +542,9 @@
|
|||||||
<span class="panel-sub">{$t('redeployTriggers.detail.bindingsSub')}</span>
|
<span class="panel-sub">{$t('redeployTriggers.detail.bindingsSub')}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if bindings.length === 0}
|
{#if !detailsLoaded}
|
||||||
|
<div class="skeleton-rows" aria-busy="true"><div class="skeleton-row"></div></div>
|
||||||
|
{:else if bindings.length === 0}
|
||||||
<div class="note muted-note">
|
<div class="note muted-note">
|
||||||
<span class="note-tag">∅</span>
|
<span class="note-tag">∅</span>
|
||||||
<p>{$t('redeployTriggers.detail.bindingsEmpty')}</p>
|
<p>{$t('redeployTriggers.detail.bindingsEmpty')}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user