From 52c67d6d920425e2e92eba4ef9c77ee7fdf4d2fa Mon Sep 17 00:00:00 2001 From: Viet Tran Date: Wed, 1 Apr 2026 15:25:59 +0700 Subject: [PATCH] feat(build): embed web UI in backend binary + simplify Docker variants (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal/webui/ package with //go:build embedui tag for optional SPA embedding (handler.go serves static files with SPA fallback) - Add internal/version/ shared semver comparison (DRY: extracted from gateway/update_check.go and updater/updater.go) - Enhance UpdateChecker: release notes, ETag caching, filter lite-v* tags - Add web UI build stage to Dockerfile with ENABLE_EMBEDUI build arg - Simplify CI: 7 Docker variants → 4 (base, latest, full, otel) - Add SHA256 checksums job to release workflow - Add Makefile build-full target (embeds web UI in Go binary) - Default make up now embeds web UI (no separate nginx needed) - Add WITH_WEB_NGINX=1 flag for optional nginx reverse proxy - Update README + 30 translated READMEs: make up, port 18790 - Update docker-compose comments and prepare-env.sh - About dialog: show release notes with markdown rendering - Health card: amber badge for available updates BREAKING: Default Docker setup no longer requires selfservice overlay. Web dashboard served at :18790 (same port as API). --- .dockerignore | 4 +- .github/workflows/docker-publish.yaml | 62 ++------ .github/workflows/release.yaml | 105 +++++++------ .gitignore | 2 +- Dockerfile | 20 ++- Makefile | 21 ++- README.md | 32 +++- _readmes/README.ar.md | 5 +- _readmes/README.bn.md | 5 +- _readmes/README.cs.md | 5 +- _readmes/README.da.md | 5 +- _readmes/README.de.md | 5 +- _readmes/README.el.md | 5 +- _readmes/README.es.md | 5 +- _readmes/README.fi.md | 5 +- _readmes/README.fr.md | 5 +- _readmes/README.he.md | 5 +- _readmes/README.hi.md | 5 +- _readmes/README.hu.md | 5 +- _readmes/README.id.md | 5 +- _readmes/README.it.md | 5 +- _readmes/README.ja.md | 5 +- _readmes/README.ko.md | 5 +- _readmes/README.nb.md | 5 +- _readmes/README.nl.md | 5 +- _readmes/README.pl.md | 5 +- _readmes/README.pt.md | 5 +- _readmes/README.ro.md | 5 +- _readmes/README.ru.md | 5 +- _readmes/README.sv.md | 5 +- _readmes/README.th.md | 5 +- _readmes/README.tl.md | 5 +- _readmes/README.tr.md | 5 +- _readmes/README.uk.md | 5 +- _readmes/README.ur.md | 5 +- _readmes/README.vi.md | 5 +- _readmes/README.zh-CN.md | 5 +- docker-compose.postgres.yml | 5 +- docker-compose.selfservice.yml | 13 +- docker-compose.yml | 27 +++- internal/gateway/router.go | 1 + internal/gateway/server.go | 7 + internal/gateway/update_check.go | 140 ++++++++---------- internal/updater/updater.go | 33 +---- internal/version/semver.go | 47 ++++++ internal/webui/embed.go | 23 +++ internal/webui/embed_stub.go | 13 ++ internal/webui/handler.go | 58 ++++++++ prepare-env.sh | 5 +- scripts/install.sh | 7 + ui/web/src/components/layout/about-dialog.tsx | 114 +++++++------- ui/web/src/i18n/locales/en/topbar.json | 3 +- ui/web/src/i18n/locales/vi/topbar.json | 3 +- ui/web/src/i18n/locales/zh/topbar.json | 3 +- .../src/pages/overview/system-health-card.tsx | 13 +- ui/web/src/pages/overview/types.ts | 1 + 56 files changed, 531 insertions(+), 381 deletions(-) create mode 100644 internal/version/semver.go create mode 100644 internal/webui/embed.go create mode 100644 internal/webui/embed_stub.go create mode 100644 internal/webui/handler.go diff --git a/.dockerignore b/.dockerignore index 70d41f48..47dddb0f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,8 @@ tmp/ .claude/ .vscode/ .idea/ -ui/ +ui/desktop/ +ui/simple-saas/ +!ui/web/ plans/ skills-store/ diff --git a/.github/workflows/docker-publish.yaml b/.github/workflows/docker-publish.yaml index bbf61faf..67817476 100644 --- a/.github/workflows/docker-publish.yaml +++ b/.github/workflows/docker-publish.yaml @@ -19,63 +19,33 @@ jobs: strategy: matrix: include: - # ── Runtime variants ── + # Base: backend API-only, no web UI, no runtimes + - variant: base + suffix: "-base" + enable_otel: "false" + enable_embedui: "false" + enable_python: "false" + enable_full_skills: "false" + # Latest: backend + embedded web UI + Python - variant: latest suffix: "" enable_otel: "false" - enable_tsnet: "false" - enable_redis: "false" - enable_node: "false" - enable_python: "false" - enable_full_skills: "false" - - variant: node - suffix: "-node" - enable_otel: "false" - enable_tsnet: "false" - enable_redis: "false" - enable_node: "true" - enable_python: "false" - enable_full_skills: "false" - - variant: python - suffix: "-python" - enable_otel: "false" - enable_tsnet: "false" - enable_redis: "false" - enable_node: "false" + enable_embedui: "true" enable_python: "true" enable_full_skills: "false" + # Full: all runtimes + skills pre-installed - variant: full suffix: "-full" enable_otel: "false" - enable_tsnet: "false" - enable_redis: "false" - enable_node: "true" + enable_embedui: "true" enable_python: "true" enable_full_skills: "true" - # ── Build-tag variants ── + # OTel: latest + OpenTelemetry tracing - variant: otel suffix: "-otel" enable_otel: "true" - enable_tsnet: "false" - enable_redis: "false" - enable_node: "false" - enable_python: "false" - enable_full_skills: "false" - - variant: tsnet - suffix: "-tsnet" - enable_otel: "false" - enable_tsnet: "true" - enable_redis: "false" - enable_node: "false" - enable_python: "false" - enable_full_skills: "false" - - variant: redis - suffix: "-redis" - enable_otel: "false" - enable_tsnet: "false" - enable_redis: "true" - enable_node: "false" - enable_python: "false" + enable_embedui: "true" + enable_python: "true" enable_full_skills: "false" steps: @@ -124,9 +94,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} build-args: | ENABLE_OTEL=${{ matrix.enable_otel }} - ENABLE_TSNET=${{ matrix.enable_tsnet }} - ENABLE_REDIS=${{ matrix.enable_redis }} - ENABLE_NODE=${{ matrix.enable_node }} + ENABLE_EMBEDUI=${{ matrix.enable_embedui }} ENABLE_PYTHON=${{ matrix.enable_python }} ENABLE_FULL_SKILLS=${{ matrix.enable_full_skills }} VERSION=${{ github.ref_name }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6a260fee..6361771f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -53,13 +53,24 @@ jobs: go-version-file: go.mod cache-dependency-path: go.sum + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build web UI + run: | + corepack enable && corepack prepare pnpm@10.28.2 --activate + cd ui/web && pnpm install --frozen-lockfile && pnpm build && cd ../.. + mkdir -p internal/webui/dist + cp -r ui/web/dist/* internal/webui/dist/ + - name: Build binary env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} VERSION: v${{ needs.release.outputs.version }} run: | - CGO_ENABLED=0 go build \ + CGO_ENABLED=0 go build -tags embedui \ -ldflags="-s -w -X github.com/nextlevelbuilder/goclaw/cmd.Version=${VERSION}" \ -o goclaw . tar -czf "goclaw-${{ needs.release.outputs.version }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" goclaw migrations/ @@ -72,6 +83,38 @@ jobs: "goclaw-${{ needs.release.outputs.version }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" \ --clobber + # Generate SHA256 checksums for all binary assets + checksums: + needs: [release, build-binaries] + if: needs.release.outputs.released == 'true' + runs-on: ubuntu-latest + steps: + - name: Download all binary assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p assets + gh release download "v${{ needs.release.outputs.version }}" \ + --repo "${{ github.repository }}" \ + --pattern "goclaw-*.tar.gz" \ + --dir assets + + - name: Generate checksums + run: | + cd assets + sha256sum goclaw-*.tar.gz > CHECKSUMS.sha256 + echo "Generated checksums:" + cat CHECKSUMS.sha256 + + - name: Upload checksums to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "v${{ needs.release.outputs.version }}" \ + assets/CHECKSUMS.sha256 \ + --repo "${{ github.repository }}" \ + --clobber + # Build and push Docker images to GHCR + Docker Hub docker-images: needs: release @@ -80,61 +123,33 @@ jobs: strategy: matrix: include: + # Base: backend API-only, no web UI, no runtimes + - variant: base + suffix: "-base" + enable_otel: "false" + enable_embedui: "false" + enable_python: "false" + enable_full_skills: "false" + # Latest: backend + embedded web UI + Python - variant: latest suffix: "" enable_otel: "false" - enable_tsnet: "false" - enable_redis: "false" - enable_node: "false" - enable_python: "false" - enable_full_skills: "false" - - variant: node - suffix: "-node" - enable_otel: "false" - enable_tsnet: "false" - enable_redis: "false" - enable_node: "true" - enable_python: "false" - enable_full_skills: "false" - - variant: python - suffix: "-python" - enable_otel: "false" - enable_tsnet: "false" - enable_redis: "false" - enable_node: "false" + enable_embedui: "true" enable_python: "true" enable_full_skills: "false" + # Full: all runtimes + skills pre-installed - variant: full suffix: "-full" enable_otel: "false" - enable_tsnet: "false" - enable_redis: "false" - enable_node: "true" + enable_embedui: "true" enable_python: "true" enable_full_skills: "true" + # OTel: latest + OpenTelemetry tracing - variant: otel suffix: "-otel" enable_otel: "true" - enable_tsnet: "false" - enable_redis: "false" - enable_node: "false" - enable_python: "false" - enable_full_skills: "false" - - variant: tsnet - suffix: "-tsnet" - enable_otel: "false" - enable_tsnet: "true" - enable_redis: "false" - enable_node: "false" - enable_python: "false" - enable_full_skills: "false" - - variant: redis - suffix: "-redis" - enable_otel: "false" - enable_tsnet: "false" - enable_redis: "true" - enable_node: "false" - enable_python: "false" + enable_embedui: "true" + enable_python: "true" enable_full_skills: "false" steps: - uses: actions/checkout@v4 @@ -178,9 +193,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} build-args: | ENABLE_OTEL=${{ matrix.enable_otel }} - ENABLE_TSNET=${{ matrix.enable_tsnet }} - ENABLE_REDIS=${{ matrix.enable_redis }} - ENABLE_NODE=${{ matrix.enable_node }} + ENABLE_EMBEDUI=${{ matrix.enable_embedui }} ENABLE_PYTHON=${{ matrix.enable_python }} ENABLE_FULL_SKILLS=${{ matrix.enable_full_skills }} VERSION=v${{ needs.release.outputs.version }} diff --git a/.gitignore b/.gitignore index 269ad676..142b218d 100644 --- a/.gitignore +++ b/.gitignore @@ -75,7 +75,7 @@ goclaw.db-shm .opencode/ # Auto-generated by Makefile (git describe) -VERSION +/VERSION ui/desktop/frontend/dist/index.html # Local compose modules diff --git a/Dockerfile b/Dockerfile index 09b04637..97853f52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,15 @@ # syntax=docker/dockerfile:1 -# ── Stage 1: Build ── +# ── Stage 0: Build Web UI (conditional) ── +FROM node:22-alpine AS web-builder +RUN corepack enable && corepack prepare pnpm@10.28.2 --activate +WORKDIR /app +COPY ui/web/package.json ui/web/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY ui/web/ . +RUN pnpm build + +# ── Stage 1: Build Go ── FROM golang:1.26-bookworm AS builder WORKDIR /src @@ -16,15 +25,22 @@ COPY . . ARG ENABLE_OTEL=false ARG ENABLE_TSNET=false ARG ENABLE_REDIS=false +ARG ENABLE_EMBEDUI=false ARG VERSION= +# Copy web UI dist for embedding (only used when ENABLE_EMBEDUI=true) +COPY --from=web-builder /app/dist /src/internal/webui/dist + # Build static binary (CGO disabled for scratch/alpine compatibility) # Version: build arg > VERSION file (auto-generated by Makefile) > "dev" RUN set -eux; \ if [ -z "$VERSION" ] && [ -f VERSION ]; then VERSION=$(cat VERSION); fi; \ if [ -z "$VERSION" ]; then VERSION="dev"; fi; \ TAGS=""; \ - if [ "$ENABLE_OTEL" = "true" ]; then TAGS="otel"; fi; \ + if [ "$ENABLE_EMBEDUI" = "true" ]; then TAGS="embedui"; fi; \ + if [ "$ENABLE_OTEL" = "true" ]; then \ + if [ -n "$TAGS" ]; then TAGS="$TAGS,otel"; else TAGS="otel"; fi; \ + fi; \ if [ "$ENABLE_TSNET" = "true" ]; then \ if [ -n "$TAGS" ]; then TAGS="$TAGS,tsnet"; else TAGS="tsnet"; fi; \ fi; \ diff --git a/Makefile b/Makefile index 7f743d4e..e83a6c86 100644 --- a/Makefile +++ b/Makefile @@ -2,21 +2,36 @@ VERSION ?= $(shell git describe --tags --abbrev=0 --match "v[0-9]*" 2>/dev/null LDFLAGS = -s -w -X github.com/nextlevelbuilder/goclaw/cmd.Version=$(VERSION) BINARY = goclaw -.PHONY: build run clean version up down logs reset test vet check-web dev migrate setup ci desktop-dev desktop-build desktop-dmg +.PHONY: build build-full run clean version up down logs reset test vet check-web dev migrate setup ci desktop-dev desktop-build desktop-dmg +# Build backend only (API-only, no embedded web UI) build: CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o $(BINARY) . +# Build with embedded web UI (recommended for production) +build-full: check-web + rm -rf internal/webui/dist && mkdir -p internal/webui/dist + cp -r ui/web/dist/* internal/webui/dist/ + CGO_ENABLED=0 go build -tags embedui -ldflags="$(LDFLAGS)" -o $(BINARY) . + run: build ./$(BINARY) clean: rm -f $(BINARY) + rm -rf internal/webui/dist version: @echo $(VERSION) -COMPOSE_BASE = docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml +# ── Docker Compose ── +# Default: backend (with embedded web UI) + Postgres. No separate nginx needed. +# Add WITH_WEB_NGINX=1 for separate nginx on :3000 (custom SSL, reverse proxy). +COMPOSE_BASE = docker compose -f docker-compose.yml -f docker-compose.postgres.yml +ifdef WITH_WEB_NGINX +COMPOSE_BASE += -f docker-compose.selfservice.yml +export ENABLE_EMBEDUI=false +endif COMPOSE_EXTRA = ifdef WITH_BROWSER COMPOSE_EXTRA += -f docker-compose.browser.yml @@ -43,7 +58,7 @@ version-file: @echo $(VERSION) > VERSION up: version-file - $(COMPOSE) up -d --build + GOCLAW_VERSION=$(VERSION) $(COMPOSE) up -d --build $(UPGRADE) run --rm upgrade down: diff --git a/README.md b/README.md index 1e221a3b..9c464479 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,11 @@ chmod +x prepare-env.sh && ./prepare-env.sh # Add at least one GOCLAW_*_API_KEY to .env, then: make up -# Web Dashboard at http://localhost:3000 +# Web Dashboard at http://localhost:18790 (built-in) # Health check: curl http://localhost:18790/health + +# Optional: separate nginx for custom SSL/reverse proxy +# make up WITH_WEB_NGINX=1 → Dashboard at http://localhost:3000 ``` `make up` creates a Docker network, embeds the correct version from git tags, builds and starts all services, and runs database migrations automatically. @@ -222,9 +225,32 @@ make down WITH_BROWSER=1 WITH_OTEL=1 When `GOCLAW_*_API_KEY` environment variables are set, the gateway auto-onboards without interactive prompts — detects provider, runs migrations, and seeds default data. -> `ghcr.io/nextlevelbuilder/goclaw:latest` is the minimal runtime image and does **not** include Python or Node.js by default. If you need those runtimes preinstalled, use the `python`, `node`, or `full` image variant, run `./scripts/setup-docker.sh --variant `, or rebuild locally with `ENABLE_PYTHON=true`, `ENABLE_NODE=true`, or `ENABLE_FULL_SKILLS=true`. +> **Docker image variants:** +> | Image | Description | +> |-------|-------------| +> | `latest` | Backend + embedded web UI + Python (**recommended**) | +> | `latest-base` | Backend API-only, no web UI, no runtimes | +> | `latest-full` | All runtimes + skill dependencies pre-installed | +> | `latest-otel` | Latest + OpenTelemetry tracing | +> | `goclaw-web` | Standalone nginx + React SPA (for custom reverse proxy) | +> +> For custom builds (Tailscale, Redis): `docker build --build-arg ENABLE_TSNET=true ...` +> See the [Deployment Guide](https://docs.goclaw.sh/#deploy-docker-compose) for details. -> For build variants (OTel, Tailscale, Redis), Docker image tags, and compose overlays, see the [Deployment Guide](https://docs.goclaw.sh/#deploy-docker-compose). +## Updating + +### Docker +```bash +docker compose pull && docker compose up -d +``` + +### Binary (with embedded web UI) +```bash +goclaw update --apply # Downloads, verifies SHA256, swaps binary, restarts +``` + +### Web Dashboard +Open **About** dialog → click **Update Now** (admin only). The update includes both backend and web dashboard when using the default `latest` image. ## Multi-Agent Orchestration diff --git a/_readmes/README.ar.md b/_readmes/README.ar.md index a1c049d0..1d5a16d4 100644 --- a/_readmes/README.ar.md +++ b/_readmes/README.ar.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # أضف على الأقل مفتاح GOCLAW_*_API_KEY إلى .env، ثم: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# لوحة تحكم الويب على http://localhost:3000 +# لوحة تحكم الويب على http://localhost:18790 # فحص الصحة: curl http://localhost:18790/health ``` diff --git a/_readmes/README.bn.md b/_readmes/README.bn.md index aa0254bb..594bd905 100644 --- a/_readmes/README.bn.md +++ b/_readmes/README.bn.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Add at least one GOCLAW_*_API_KEY to .env, then: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Dashboard at http://localhost:3000 +# Web Dashboard at http://localhost:18790 # Health check: curl http://localhost:18790/health ``` diff --git a/_readmes/README.cs.md b/_readmes/README.cs.md index 9ecb90df..2589b2f4 100644 --- a/_readmes/README.cs.md +++ b/_readmes/README.cs.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Přidat alespoň jeden GOCLAW_*_API_KEY do .env, poté: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Webový dashboard na http://localhost:3000 +# Webový dashboard na http://localhost:18790 # Kontrola stavu: curl http://localhost:18790/health ``` diff --git a/_readmes/README.da.md b/_readmes/README.da.md index 4fcf1324..27a45ba3 100644 --- a/_readmes/README.da.md +++ b/_readmes/README.da.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Tilføj mindst én GOCLAW_*_API_KEY til .env, derefter: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Dashboard på http://localhost:3000 +# Web Dashboard på http://localhost:18790 # Sundhedstjek: curl http://localhost:18790/health ``` diff --git a/_readmes/README.de.md b/_readmes/README.de.md index 941c4c5e..de2aadb5 100644 --- a/_readmes/README.de.md +++ b/_readmes/README.de.md @@ -111,10 +111,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Mindestens einen GOCLAW_*_API_KEY in .env hinzufügen, dann: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web-Dashboard unter http://localhost:3000 +# Web-Dashboard unter http://localhost:18790 # Health-Check: curl http://localhost:18790/health ``` diff --git a/_readmes/README.el.md b/_readmes/README.el.md index 13744e37..ad760ba5 100644 --- a/_readmes/README.el.md +++ b/_readmes/README.el.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Add at least one GOCLAW_*_API_KEY to .env, then: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Dashboard at http://localhost:3000 +# Web Dashboard at http://localhost:18790 # Health check: curl http://localhost:18790/health ``` diff --git a/_readmes/README.es.md b/_readmes/README.es.md index c38f602f..e93d4c31 100644 --- a/_readmes/README.es.md +++ b/_readmes/README.es.md @@ -111,10 +111,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Añade al menos una GOCLAW_*_API_KEY al .env, luego: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Panel web en http://localhost:3000 +# Panel web en http://localhost:18790 # Verificación de estado: curl http://localhost:18790/health ``` diff --git a/_readmes/README.fi.md b/_readmes/README.fi.md index 0c4a2ad3..10ae44f7 100644 --- a/_readmes/README.fi.md +++ b/_readmes/README.fi.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Lisää vähintään yksi GOCLAW_*_API_KEY tiedostoon .env, sitten: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web-koontinäyttö osoitteessa http://localhost:3000 +# Web-koontinäyttö osoitteessa http://localhost:18790 # Terveystarkistus: curl http://localhost:18790/health ``` diff --git a/_readmes/README.fr.md b/_readmes/README.fr.md index 9b4f8afe..693d3882 100644 --- a/_readmes/README.fr.md +++ b/_readmes/README.fr.md @@ -111,10 +111,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Ajouter au moins une GOCLAW_*_API_KEY dans .env, puis : -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Tableau de bord web sur http://localhost:3000 +# Tableau de bord web sur http://localhost:18790 # Vérification de santé : curl http://localhost:18790/health ``` diff --git a/_readmes/README.he.md b/_readmes/README.he.md index d58f6df1..39aa248c 100644 --- a/_readmes/README.he.md +++ b/_readmes/README.he.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # הוסף לפחות GOCLAW_*_API_KEY אחד ל-.env, ואז: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# לוח הבקרה הווב בכתובת http://localhost:3000 +# לוח הבקרה הווב בכתובת http://localhost:18790 # בדיקת תקינות: curl http://localhost:18790/health ``` diff --git a/_readmes/README.hi.md b/_readmes/README.hi.md index 6ee2e92b..7ae2f729 100644 --- a/_readmes/README.hi.md +++ b/_readmes/README.hi.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Add at least one GOCLAW_*_API_KEY to .env, then: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Dashboard at http://localhost:3000 +# Web Dashboard at http://localhost:18790 # Health check: curl http://localhost:18790/health ``` diff --git a/_readmes/README.hu.md b/_readmes/README.hu.md index ce68450e..700824ec 100644 --- a/_readmes/README.hu.md +++ b/_readmes/README.hu.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Adj meg legalább egy GOCLAW_*_API_KEY értéket a .env fájlban, majd: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Webes irányítópult: http://localhost:3000 +# Webes irányítópult: http://localhost:18790 # Állapotfelmérés: curl http://localhost:18790/health ``` diff --git a/_readmes/README.id.md b/_readmes/README.id.md index 6146e9cd..77fe36a4 100644 --- a/_readmes/README.id.md +++ b/_readmes/README.id.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Tambahkan minimal satu GOCLAW_*_API_KEY ke .env, lalu: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Dasbor Web di http://localhost:3000 +# Dasbor Web di http://localhost:18790 # Pemeriksaan kesehatan: curl http://localhost:18790/health ``` diff --git a/_readmes/README.it.md b/_readmes/README.it.md index 88daae51..0aa42dc4 100644 --- a/_readmes/README.it.md +++ b/_readmes/README.it.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Aggiungi almeno un GOCLAW_*_API_KEY a .env, poi: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Dashboard su http://localhost:3000 +# Web Dashboard su http://localhost:18790 # Health check: curl http://localhost:18790/health ``` diff --git a/_readmes/README.ja.md b/_readmes/README.ja.md index d9b52081..82aa80cb 100644 --- a/_readmes/README.ja.md +++ b/_readmes/README.ja.md @@ -111,10 +111,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Add at least one GOCLAW_*_API_KEY to .env, then: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Dashboard at http://localhost:3000 +# Web Dashboard at http://localhost:18790 # Health check: curl http://localhost:18790/health ``` diff --git a/_readmes/README.ko.md b/_readmes/README.ko.md index 44526fde..5771740b 100644 --- a/_readmes/README.ko.md +++ b/_readmes/README.ko.md @@ -111,10 +111,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Add at least one GOCLAW_*_API_KEY to .env, then: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Dashboard at http://localhost:3000 +# Web Dashboard at http://localhost:18790 # Health check: curl http://localhost:18790/health ``` diff --git a/_readmes/README.nb.md b/_readmes/README.nb.md index b919cccd..631a235c 100644 --- a/_readmes/README.nb.md +++ b/_readmes/README.nb.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Legg til minst én GOCLAW_*_API_KEY i .env, deretter: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web-dashbord på http://localhost:3000 +# Web-dashbord på http://localhost:18790 # Helsesjekk: curl http://localhost:18790/health ``` diff --git a/_readmes/README.nl.md b/_readmes/README.nl.md index 67d2b8ea..6ee72027 100644 --- a/_readmes/README.nl.md +++ b/_readmes/README.nl.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Voeg minimaal één GOCLAW_*_API_KEY toe aan .env, dan: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Webdashboard op http://localhost:3000 +# Webdashboard op http://localhost:18790 # Statuscontrole: curl http://localhost:18790/health ``` diff --git a/_readmes/README.pl.md b/_readmes/README.pl.md index 4047fa55..e39c8ca0 100644 --- a/_readmes/README.pl.md +++ b/_readmes/README.pl.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Dodaj co najmniej jeden GOCLAW_*_API_KEY do .env, a następnie: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Panel webowy pod adresem http://localhost:3000 +# Panel webowy pod adresem http://localhost:18790 # Sprawdzenie stanu: curl http://localhost:18790/health ``` diff --git a/_readmes/README.pt.md b/_readmes/README.pt.md index 3e32de41..c61e3646 100644 --- a/_readmes/README.pt.md +++ b/_readmes/README.pt.md @@ -111,10 +111,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Adicione pelo menos uma GOCLAW_*_API_KEY ao .env, depois: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Painel Web em http://localhost:3000 +# Painel Web em http://localhost:18790 # Verificação de saúde: curl http://localhost:18790/health ``` diff --git a/_readmes/README.ro.md b/_readmes/README.ro.md index 9d9c9c16..54309822 100644 --- a/_readmes/README.ro.md +++ b/_readmes/README.ro.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Adaugă cel puțin un GOCLAW_*_API_KEY în .env, apoi: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Tablou de bord web la http://localhost:3000 +# Tablou de bord web la http://localhost:18790 # Verificare stare: curl http://localhost:18790/health ``` diff --git a/_readmes/README.ru.md b/_readmes/README.ru.md index 3b37b272..360c9a8e 100644 --- a/_readmes/README.ru.md +++ b/_readmes/README.ru.md @@ -111,10 +111,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Добавьте как минимум один GOCLAW_*_API_KEY в .env, затем: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Веб-дэшборд: http://localhost:3000 +# Веб-дэшборд: http://localhost:18790 # Проверка работоспособности: curl http://localhost:18790/health ``` diff --git a/_readmes/README.sv.md b/_readmes/README.sv.md index 2ef32675..c3863137 100644 --- a/_readmes/README.sv.md +++ b/_readmes/README.sv.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Lägg till minst en GOCLAW_*_API_KEY i .env, sedan: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Webbinstrumentpanel på http://localhost:3000 +# Webbinstrumentpanel på http://localhost:18790 # Hälsokontroll: curl http://localhost:18790/health ``` diff --git a/_readmes/README.th.md b/_readmes/README.th.md index 0f69dda8..9a2acc82 100644 --- a/_readmes/README.th.md +++ b/_readmes/README.th.md @@ -113,10 +113,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # เพิ่ม GOCLAW_*_API_KEY อย่างน้อยหนึ่งรายการใน .env จากนั้น: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Dashboard ที่ http://localhost:3000 +# Web Dashboard ที่ http://localhost:18790 # ตรวจสอบสถานะ: curl http://localhost:18790/health ``` diff --git a/_readmes/README.tl.md b/_readmes/README.tl.md index 78ba82b1..4a9fac4f 100644 --- a/_readmes/README.tl.md +++ b/_readmes/README.tl.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Magdagdag ng kahit isang GOCLAW_*_API_KEY sa .env, pagkatapos: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Dashboard sa http://localhost:3000 +# Web Dashboard sa http://localhost:18790 # Health check: curl http://localhost:18790/health ``` diff --git a/_readmes/README.tr.md b/_readmes/README.tr.md index a64d0ff0..456116e6 100644 --- a/_readmes/README.tr.md +++ b/_readmes/README.tr.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # .env dosyasına en az bir GOCLAW_*_API_KEY ekleyin, ardından: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Panosu: http://localhost:3000 +# Web Panosu: http://localhost:18790 # Sağlık kontrolü: curl http://localhost:18790/health ``` diff --git a/_readmes/README.uk.md b/_readmes/README.uk.md index 3bb5668a..d95d05b9 100644 --- a/_readmes/README.uk.md +++ b/_readmes/README.uk.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Додайте хоча б один GOCLAW_*_API_KEY до .env, потім: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Веб-панель за адресою http://localhost:3000 +# Веб-панель за адресою http://localhost:18790 # Перевірка стану: curl http://localhost:18790/health ``` diff --git a/_readmes/README.ur.md b/_readmes/README.ur.md index 07def959..f76c04b5 100644 --- a/_readmes/README.ur.md +++ b/_readmes/README.ur.md @@ -133,10 +133,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # .env میں کم از کم ایک GOCLAW_*_API_KEY شامل کریں، پھر: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# ویب ڈیش بورڈ http://localhost:3000 پر +# ویب ڈیش بورڈ http://localhost:18790 پر # ہیلتھ چیک: curl http://localhost:18790/health ``` diff --git a/_readmes/README.vi.md b/_readmes/README.vi.md index a1d00eaa..87af5033 100644 --- a/_readmes/README.vi.md +++ b/_readmes/README.vi.md @@ -111,10 +111,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # Thêm ít nhất một GOCLAW_*_API_KEY vào .env, sau đó: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web Dashboard tại http://localhost:3000 +# Web Dashboard tại http://localhost:18790 # Kiểm tra trạng thái: curl http://localhost:18790/health ``` diff --git a/_readmes/README.zh-CN.md b/_readmes/README.zh-CN.md index 78358518..035fd8f6 100644 --- a/_readmes/README.zh-CN.md +++ b/_readmes/README.zh-CN.md @@ -111,10 +111,9 @@ source .env.local && ./goclaw chmod +x prepare-env.sh && ./prepare-env.sh # 在 .env 中至少添加一个 GOCLAW_*_API_KEY,然后: -docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d +make up -# Web 控制台地址:http://localhost:3000 +# Web 控制台地址:http://localhost:18790 # 健康检查:curl http://localhost:18790/health ``` diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml index 4b51565a..10eebdda 100644 --- a/docker-compose.postgres.yml +++ b/docker-compose.postgres.yml @@ -1,7 +1,8 @@ # PostgreSQL overlay — pgvector for multi-tenant storage. # -# Usage (with Web Dashboard): -# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d --build +# Usage (web dashboard built-in by default): +# make up +# # or: docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d --build # # Required env vars (set in .env or shell): # GOCLAW_OPENROUTER_API_KEY (or another provider key) diff --git a/docker-compose.selfservice.yml b/docker-compose.selfservice.yml index fe4a0e40..fa332e04 100644 --- a/docker-compose.selfservice.yml +++ b/docker-compose.selfservice.yml @@ -1,9 +1,14 @@ -# Self-service overlay — adds web dashboard UI (nginx + React SPA). +# Self-service overlay — adds separate nginx web dashboard UI. # -# Recommended usage: -# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d --build +# OPTIONAL: The default `latest` image already includes an embedded web dashboard +# served at port 18790. Use this overlay only if you need a dedicated nginx +# reverse proxy for SSL termination, custom caching, or load balancing. # -# Dashboard: http://localhost:3000 +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d +# # or: make up WITH_WEB_NGINX=1 +# +# Dashboard via nginx: http://localhost:3000 services: goclaw-ui: diff --git a/docker-compose.yml b/docker-compose.yml index 415029cd..aed3e2a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,26 @@ # Base docker-compose — shared service definition. # Pre-built images: ghcr.io/nextlevelbuilder/goclaw (also on Docker Hub: digitop/goclaw) # -# Combine with overlays for your deployment: +# The default `latest` image includes an embedded web dashboard (no separate nginx needed). +# Web UI is served at the same port as the API (default: 18790). # -# Recommended (+ Web Dashboard, pre-built images): -# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d +# Quick start: +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d +# # Dashboard: http://localhost:18790 # -# Build from source (add --build): -# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d --build +# Optional nginx (for custom SSL, reverse proxy, caching): +# make up WITH_WEB_NGINX=1 +# # or: docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d +# # Dashboard via nginx: http://localhost:3000 # -# Without dashboard: docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d -# With OTel tracing: docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.otel.yml up -d --build -# With Claude CLI: add -f docker-compose.claude-cli.yml to auto-sync host ~/.claude credentials +# Docker image variants: +# latest — Backend + embedded web UI + Python (recommended) +# latest-base — Backend API-only, no web UI, no runtimes +# latest-full — All runtimes + skill dependencies pre-installed +# latest-otel — Latest + OpenTelemetry tracing +# +# With OTel tracing: add -f docker-compose.otel.yml +# With Claude CLI: add -f docker-compose.claude-cli.yml services: goclaw: @@ -21,9 +30,11 @@ services: dockerfile: Dockerfile args: ENABLE_OTEL: "${ENABLE_OTEL:-false}" + ENABLE_EMBEDUI: "${ENABLE_EMBEDUI:-true}" # set to false when using WITH_WEB_NGINX ENABLE_PYTHON: "${ENABLE_PYTHON:-true}" ENABLE_FULL_SKILLS: "${ENABLE_FULL_SKILLS:-false}" VERSION: "${GOCLAW_VERSION:-dev}" + # Uncomment to test update UI: GOCLAW_VERSION=v1.0.0 ports: - "${GOCLAW_PORT:-18790}:18790" env_file: diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 7a4fcf3d..5972bf7b 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -464,6 +464,7 @@ func (r *MethodRouter) handleHealth(ctx context.Context, client *Client, req *pr resp["latestVersion"] = info.LatestVersion resp["updateAvailable"] = info.UpdateAvailable resp["updateUrl"] = info.UpdateURL + resp["releaseNotes"] = info.ReleaseNotes } } client.SendResponse(protocol.NewOKResponse(req.ID, resp)) diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 107a3c7a..3a6adbac 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -21,6 +21,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" + "github.com/nextlevelbuilder/goclaw/internal/webui" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -196,6 +197,12 @@ func (s *Server) BuildMux() *http.ServeMux { } } + // Embedded web UI (built with -tags embedui). Catch-all after all API routes. + if h := webui.Handler(); h != nil { + mux.Handle("/", h) + slog.Info("serving embedded web UI") + } + s.mux = mux return mux } diff --git a/internal/gateway/update_check.go b/internal/gateway/update_check.go index af5b31c0..740adffa 100644 --- a/internal/gateway/update_check.go +++ b/internal/gateway/update_check.go @@ -7,22 +7,26 @@ import ( "io" "log/slog" "net/http" - "strconv" "strings" "sync" "time" + + "github.com/nextlevelbuilder/goclaw/internal/version" ) const ( githubRepo = "nextlevelbuilder/goclaw" + liteTagPrefix = "lite-v" updateCheckInterval = 1 * time.Hour + maxResponseBody = 2 << 20 // 2 MB ) // UpdateInfo holds the latest release information from GitHub. type UpdateInfo struct { - LatestVersion string `json:"latestVersion"` - UpdateURL string `json:"updateUrl"` + LatestVersion string `json:"latestVersion"` + UpdateURL string `json:"updateUrl"` UpdateAvailable bool `json:"updateAvailable"` + ReleaseNotes string `json:"releaseNotes,omitempty"` } // UpdateChecker periodically checks GitHub for new releases. @@ -30,6 +34,7 @@ type UpdateChecker struct { currentVersion string mu sync.RWMutex info *UpdateInfo + etag string // ETag for conditional requests } // NewUpdateChecker creates an UpdateChecker for the given current version. @@ -39,7 +44,6 @@ func NewUpdateChecker(currentVersion string) *UpdateChecker { // Start begins periodic update checking. Call with a cancellable context. func (uc *UpdateChecker) Start(ctx context.Context) { - // Initial check after short delay (don't block startup) go func() { select { case <-time.After(5 * time.Second): @@ -68,11 +72,21 @@ func (uc *UpdateChecker) Info() *UpdateInfo { return uc.info } +// githubRelease is a minimal GitHub Release API response. +type githubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + func (uc *UpdateChecker) check() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo) + // Use releases list API (not /latest) to filter out lite-v* tags. + url := fmt.Sprintf("https://api.github.com/repos/%s/releases", githubRepo) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { slog.Warn("update check: failed to create request", "error", err) @@ -81,6 +95,11 @@ func (uc *UpdateChecker) check() { req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "goclaw/"+uc.currentVersion) + // ETag conditional request to reduce API rate limit usage. + if uc.etag != "" { + req.Header.Set("If-None-Match", uc.etag) + } + resp, err := http.DefaultClient.Do(req) if err != nil { slog.Warn("update check: request failed", "error", err) @@ -88,89 +107,52 @@ func (uc *UpdateChecker) check() { } defer resp.Body.Close() + // 304 Not Modified — cached info is still valid. + if resp.StatusCode == http.StatusNotModified { + return + } if resp.StatusCode != http.StatusOK { slog.Warn("update check: unexpected status", "status", resp.StatusCode) return } - var release struct { - TagName string `json:"tag_name"` - HTMLURL string `json:"html_url"` + // Cache ETag. + if etag := resp.Header.Get("ETag"); etag != "" { + uc.etag = etag } - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&release); err != nil { + + var releases []githubRelease + if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&releases); err != nil { slog.Warn("update check: failed to decode response", "error", err) return } - if release.TagName == "" { + // Find the latest non-draft, non-prerelease, non-lite server release. + for _, rel := range releases { + if rel.Draft || rel.Prerelease { + continue + } + if strings.HasPrefix(rel.TagName, liteTagPrefix) { + continue + } + if !strings.HasPrefix(rel.TagName, "v") { + continue + } + + info := &UpdateInfo{ + LatestVersion: rel.TagName, + UpdateURL: rel.HTMLURL, + UpdateAvailable: version.IsNewer(rel.TagName, uc.currentVersion), + ReleaseNotes: rel.Body, + } + + uc.mu.Lock() + uc.info = info + uc.mu.Unlock() + + if info.UpdateAvailable { + slog.Info("new version available", "current", uc.currentVersion, "latest", rel.TagName) + } return } - - info := &UpdateInfo{ - LatestVersion: release.TagName, - UpdateURL: release.HTMLURL, - UpdateAvailable: isNewer(uc.currentVersion, release.TagName), - } - - uc.mu.Lock() - uc.info = info - uc.mu.Unlock() - - if info.UpdateAvailable { - slog.Info("new version available", "current", uc.currentVersion, "latest", release.TagName) - } -} - -// isNewer returns true if latest is a newer version than current. -// Compares by stripping "v" prefix and doing simple string comparison. -// Returns false if current is "dev" (development build). -func isNewer(current, latest string) bool { - current = strings.TrimPrefix(current, "v") - latest = strings.TrimPrefix(latest, "v") - - if current == "dev" || current == "" || latest == "" { - return false - } - if current == latest { - return false - } - - // Semantic version comparison: split by "." and compare numerically - return compareSemver(latest, current) > 0 -} - -// compareSemver compares two semver strings (without "v" prefix). -// Returns >0 if a > b, <0 if a < b, 0 if equal. -// Handles pre-release suffixes by stripping them (e.g., "1.2.3-5-gabcdef"). -func compareSemver(a, b string) int { - partsA := parseSemver(a) - partsB := parseSemver(b) - - for i := range 3 { - va, vb := 0, 0 - if i < len(partsA) { - va = partsA[i] - } - if i < len(partsB) { - vb = partsB[i] - } - if va != vb { - return va - vb - } - } - return 0 -} - -func parseSemver(s string) []int { - // Strip pre-release suffix: "1.2.3-5-gabcdef" → "1.2.3" - if idx := strings.IndexByte(s, '-'); idx >= 0 { - s = s[:idx] - } - parts := strings.Split(s, ".") - nums := make([]int, 0, len(parts)) - for _, p := range parts { - n, _ := strconv.Atoi(p) - nums = append(nums, n) - } - return nums } diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 38b678ed..22a668d7 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -16,6 +16,8 @@ import ( "runtime" "strings" "time" + + "github.com/nextlevelbuilder/goclaw/internal/version" ) const ( @@ -336,36 +338,9 @@ func removeQuarantine(appPath string) { } } -// isNewer returns true if version a is newer than b (simple semver compare). -// Pre-release suffixes (e.g. "0.2.0-rc1") are stripped — only major.minor.patch compared. +// isNewer returns true if version a is newer than b. func isNewer(a, b string) bool { - pa := parseSemver(a) - pb := parseSemver(b) - for i := range 3 { - if pa[i] > pb[i] { - return true - } - if pa[i] < pb[i] { - return false - } - } - return false -} - -func parseSemver(s string) [3]int { - s = strings.TrimPrefix(s, "v") - // Strip pre-release suffix: "0.2.0-rc1" → "0.2.0" - if idx := strings.IndexByte(s, '-'); idx >= 0 { - s = s[:idx] - } - var parts [3]int - for i, p := range strings.SplitN(s, ".", 3) { - if i >= 3 { - break - } - fmt.Sscanf(p, "%d", &parts[i]) - } - return parts + return version.IsNewer(a, b) } // ResolveAppPath returns the path to the current .app bundle (macOS) or .exe (Windows) diff --git a/internal/version/semver.go b/internal/version/semver.go new file mode 100644 index 00000000..ec705581 --- /dev/null +++ b/internal/version/semver.go @@ -0,0 +1,47 @@ +// Package version provides shared semantic version comparison utilities. +package version + +import ( + "strconv" + "strings" +) + +// IsNewer returns true if version a is newer than b. +// Both versions may include "v" prefix and pre-release suffixes (stripped before comparison). +// Returns false if either version is "dev" or empty. +func IsNewer(a, b string) bool { + a = strings.TrimPrefix(a, "v") + b = strings.TrimPrefix(b, "v") + if a == "dev" || a == "" || b == "dev" || b == "" { + return false + } + return Compare(a, b) > 0 +} + +// Compare compares two semver strings. Returns >0 if a > b, <0 if a < b, 0 if equal. +// Strips "v" prefix and pre-release suffixes before comparison. +func Compare(a, b string) int { + pa := Parse(a) + pb := Parse(b) + for i := range 3 { + if pa[i] != pb[i] { + return pa[i] - pb[i] + } + } + return 0 +} + +// Parse extracts [major, minor, patch] from a semver string. +// Strips "v" prefix and pre-release suffixes (e.g. "v1.2.3-5-gabcdef" → [1, 2, 3]). +func Parse(s string) [3]int { + s = strings.TrimPrefix(s, "v") + // Strip pre-release suffix: "1.2.3-rc1" → "1.2.3" + if idx := strings.IndexByte(s, '-'); idx >= 0 { + s = s[:idx] + } + var parts [3]int + for i, p := range strings.SplitN(s, ".", 3) { + parts[i], _ = strconv.Atoi(p) + } + return parts +} diff --git a/internal/webui/embed.go b/internal/webui/embed.go new file mode 100644 index 00000000..4f738c43 --- /dev/null +++ b/internal/webui/embed.go @@ -0,0 +1,23 @@ +//go:build embedui + +package webui + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var assets embed.FS + +// Assets returns the embedded web UI filesystem rooted at "dist/". +func Assets() fs.FS { + sub, err := fs.Sub(assets, "dist") + if err != nil { + return nil + } + return sub +} + +// HasAssets returns true when built with the embedui tag. +func HasAssets() bool { return true } diff --git a/internal/webui/embed_stub.go b/internal/webui/embed_stub.go new file mode 100644 index 00000000..f90d1c84 --- /dev/null +++ b/internal/webui/embed_stub.go @@ -0,0 +1,13 @@ +//go:build !embedui + +// Package webui provides optional embedded web UI serving. +// When built without the embedui tag, no assets are available. +package webui + +import "io/fs" + +// Assets returns nil when built without the embedui tag. +func Assets() fs.FS { return nil } + +// HasAssets returns false when built without the embedui tag. +func HasAssets() bool { return false } diff --git a/internal/webui/handler.go b/internal/webui/handler.go new file mode 100644 index 00000000..1d9ce027 --- /dev/null +++ b/internal/webui/handler.go @@ -0,0 +1,58 @@ +package webui + +import ( + "io/fs" + "net/http" + "strings" +) + +// apiPrefixes are URL prefixes reserved for backend APIs. +// Requests matching these are never served by the SPA handler. +var apiPrefixes = []string{"/v1/", "/ws", "/health", "/mcp/"} + +// Handler returns an http.Handler that serves the embedded SPA. +// Returns nil if no assets are embedded (built without embedui tag). +func Handler() http.Handler { + fsys := Assets() + if fsys == nil { + return nil + } + fileServer := http.FileServer(http.FS(fsys)) + return &spaHandler{fs: fsys, fileServer: fileServer} +} + +type spaHandler struct { + fs fs.FS + fileServer http.Handler +} + +func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Never intercept API routes. + for _, prefix := range apiPrefixes { + if strings.HasPrefix(r.URL.Path, prefix) { + http.NotFound(w, r) + return + } + } + + // Try to serve the file directly. + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + + // Check if file exists in the embedded FS. + if _, err := fs.Stat(h.fs, path); err == nil { + // Static assets: set long cache for /assets/* (Vite hashed filenames). + if strings.HasPrefix(r.URL.Path, "/assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + h.fileServer.ServeHTTP(w, r) + return + } + + // SPA fallback: serve index.html for any unmatched route. + // This handles client-side routing (React Router). + r.URL.Path = "/" + h.fileServer.ServeHTTP(w, r) +} diff --git a/prepare-env.sh b/prepare-env.sh index 6c3058db..2315f987 100755 --- a/prepare-env.sh +++ b/prepare-env.sh @@ -86,5 +86,8 @@ fi echo "" echo "=== Done ===" echo "" -echo " Run: docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d --build" +echo " Run: make up" +echo "" +echo " Web dashboard: http://localhost:18790" +echo " With separate nginx: make up WITH_WEB_NGINX=1 → http://localhost:3000" echo "" diff --git a/scripts/install.sh b/scripts/install.sh index 26532def..8dfad4d2 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -79,6 +79,8 @@ echo "" echo "GoClaw ${VERSION} installed to ${INSTALL_DIR}/goclaw" echo "Migrations installed to ${MIGRATIONS_DIR}" echo "" +echo "The binary includes an embedded web dashboard — no separate nginx needed." +echo "" echo "Next steps:" echo " 1. Set up PostgreSQL (pgvector):" echo " docker run -d --name goclaw-pg -p 5432:5432 -e POSTGRES_PASSWORD=goclaw pgvector/pgvector:pg18" @@ -92,3 +94,8 @@ echo " goclaw onboard" echo "" echo " 4. Start the gateway:" echo " source .env.local && goclaw" +echo "" +echo " Web dashboard: http://localhost:18790" +echo " Health check: curl http://localhost:18790/health" +echo "" +echo " To update later: goclaw update --apply" diff --git a/ui/web/src/components/layout/about-dialog.tsx b/ui/web/src/components/layout/about-dialog.tsx index b971cbc0..f616dc2e 100644 --- a/ui/web/src/components/layout/about-dialog.tsx +++ b/ui/web/src/components/layout/about-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAuthStore } from "@/stores/use-auth-store"; import { useWsCall } from "@/hooks/use-ws-call"; @@ -12,6 +12,13 @@ import { DialogTitle, DialogFooter, } from "@/components/ui/dialog"; +import { + ChevronDown, + ChevronRight, + ExternalLink, +} from "lucide-react"; + +const ReactMarkdown = lazy(() => import("react-markdown")); interface AboutDialogProps { open: boolean; @@ -25,10 +32,15 @@ export function AboutDialog({ open, onOpenChange }: AboutDialogProps) { const { call: fetchHealth, data: health } = useWsCall(Methods.HEALTH); + const [notesExpanded, setNotesExpanded] = useState(false); + useEffect(() => { if (open && connected) { fetchHealth(); } + if (!open) { + setNotesExpanded(false); + } }, [open, connected, fetchHealth]); const rawVersion = health?.version || serverInfo?.version || "dev"; @@ -36,14 +48,20 @@ export function AboutDialog({ open, onOpenChange }: AboutDialogProps) { const latestVersion = health?.latestVersion; const updateAvailable = health?.updateAvailable ?? false; const updateUrl = health?.updateUrl; + const releaseNotes = health?.releaseNotes; return ( - + GoClaw {t("about.title")} + {updateAvailable && latestVersion && ( + + {latestVersion} + + )} @@ -67,69 +85,59 @@ export function AboutDialog({ open, onOpenChange }: AboutDialogProps) {
{t("about.updateAvailable", { version: latestVersion })}
+ + {/* Release notes (collapsible, markdown) */} + {releaseNotes && ( +
+ + {notesExpanded && ( +
+ {releaseNotes}}> + {releaseNotes} + +
+ )} +
+ )} + {updateUrl && ( {t("about.viewRelease")} + )} )} - {/* Source Code */} -
- {t("about.sourceCode")} - - github.com/nextlevelbuilder/goclaw - -
- - {/* License */} -
- {t("about.license")} - - CC BY-NC 4.0 - -
- - {/* Documentation */} -
- {t("about.documentation")} - - docs.goclaw.sh - -
- - {/* Report Bug */} -
- {t("about.reportBug")} - - github.com/.../issues - + {/* Links */} +
+ {[ + { label: t("about.sourceCode"), href: "https://github.com/nextlevelbuilder/goclaw" }, + { label: t("about.license"), href: "https://creativecommons.org/licenses/by-nc/4.0/" }, + { label: t("about.documentation"), href: "https://docs.goclaw.sh" }, + { label: t("about.reportBug"), href: "https://github.com/nextlevelbuilder/goclaw/issues" }, + ].map(({ label, href }) => ( + + {label} + + ))}
diff --git a/ui/web/src/i18n/locales/en/topbar.json b/ui/web/src/i18n/locales/en/topbar.json index da3888a7..2616990a 100644 --- a/ui/web/src/i18n/locales/en/topbar.json +++ b/ui/web/src/i18n/locales/en/topbar.json @@ -23,7 +23,8 @@ "reportBug": "Report a Bug", "upToDate": "Up to date", "updateAvailable": "Update available: {{version}}", - "viewRelease": "View release notes →" + "viewRelease": "View release notes →", + "releaseNotes": "Release Notes" }, "languages": { "en": "English", diff --git a/ui/web/src/i18n/locales/vi/topbar.json b/ui/web/src/i18n/locales/vi/topbar.json index c7c3c5e3..0ed802bc 100644 --- a/ui/web/src/i18n/locales/vi/topbar.json +++ b/ui/web/src/i18n/locales/vi/topbar.json @@ -26,7 +26,8 @@ "reportBug": "Báo lỗi", "upToDate": "Đã cập nhật", "updateAvailable": "Có bản cập nhật: {{version}}", - "viewRelease": "Xem chi tiết →" + "viewRelease": "Xem chi tiết →", + "releaseNotes": "Ghi chú phát hành" }, "systemSettings": "Cài đặt hệ thống", "toggleTheme": "Chuyển đổi giao diện" diff --git a/ui/web/src/i18n/locales/zh/topbar.json b/ui/web/src/i18n/locales/zh/topbar.json index 22e6c353..af5a0c0d 100644 --- a/ui/web/src/i18n/locales/zh/topbar.json +++ b/ui/web/src/i18n/locales/zh/topbar.json @@ -26,7 +26,8 @@ "reportBug": "报告问题", "upToDate": "已是最新", "updateAvailable": "有新版本: {{version}}", - "viewRelease": "查看发行说明 →" + "viewRelease": "查看发行说明 →", + "releaseNotes": "发行说明" }, "systemSettings": "系统设置", "toggleTheme": "切换主题" diff --git a/ui/web/src/pages/overview/system-health-card.tsx b/ui/web/src/pages/overview/system-health-card.tsx index e654d807..e87b097f 100644 --- a/ui/web/src/pages/overview/system-health-card.tsx +++ b/ui/web/src/pages/overview/system-health-card.tsx @@ -86,15 +86,10 @@ export function SystemHealthCard({ {health.updateAvailable === false && ( )} - {health.updateAvailable && health.updateUrl && ( - - {health.latestVersion} → - + {health.updateAvailable && health.latestVersion && ( + + {health.latestVersion} available + )} )} diff --git a/ui/web/src/pages/overview/types.ts b/ui/web/src/pages/overview/types.ts index 04435308..51440994 100644 --- a/ui/web/src/pages/overview/types.ts +++ b/ui/web/src/pages/overview/types.ts @@ -18,6 +18,7 @@ export interface HealthPayload { latestVersion?: string; updateAvailable?: boolean; updateUrl?: string; + releaseNotes?: string; } export interface AgentInfo {