feat: rename Docker Watcher to Tinyforge
Build / build (push) Successful in 12m20s

Rebrand the project as Tinyforge to reflect its evolution from a Docker
container watcher into a self-hosted mini CI/deployment platform.

Rename covers: Go module path, Docker labels, DB/config filenames,
JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend
i18n, README with static sites docs, and all code comments.
This commit is contained in:
2026-04-12 21:30:23 +03:00
parent 8d2c5a063b
commit 791cd4d6af
68 changed files with 512 additions and 224 deletions
+3 -1
View File
@@ -12,7 +12,9 @@
"Bash(git stash:*)", "Bash(git stash:*)",
"Bash(echo \"EXIT: $?\")", "Bash(echo \"EXIT: $?\")",
"Bash(./scripts/dev-server.sh)", "Bash(./scripts/dev-server.sh)",
"Bash(go doc:*)" "Bash(go doc:*)",
"Bash(ls -la /c/Users/Alexei/Documents/docker-watcher/internal/*/)",
"Bash(go get:*)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"C:\\Users\\Alexei\\Documents\\docker-watcher\\internal", "C:\\Users\\Alexei\\Documents\\docker-watcher\\internal",
+38
View File
@@ -0,0 +1,38 @@
name: Build
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- 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
run: go vet ./...
- name: Build Go binary
run: CGO_ENABLED=0 go build -ldflags="-s -w" -o tinyforge ./cmd/server
- name: Build Docker image
run: docker build -t tinyforge:dev .
+115
View File
@@ -0,0 +1,115 @@
name: Release
on:
push:
tags:
- 'v*'
env:
SERVER_HOST: git.dolgolyov-family.by
REGISTRY: git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge
jobs:
create-release:
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create.outputs.release_id }}
steps:
- name: Fetch RELEASE_NOTES.md only
uses: actions/checkout@v4
with:
sparse-checkout: RELEASE_NOTES.md
sparse-checkout-cone-mode: false
- name: Create Gitea release
id: create
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
# Detect pre-release (alpha/beta/rc)
IS_PRE="false"
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
IS_PRE="true"
fi
# Read release notes if present
if [ -f RELEASE_NOTES.md ]; then
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
else
export RELEASE_NOTES=""
echo "No RELEASE_NOTES.md found — release will have no body"
fi
BODY_JSON=$(python3 -c "
import json, os
notes = os.environ.get('RELEASE_NOTES', '')
print(json.dumps(notes.strip()))
")
# Create release via Gitea API
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"$VERSION\",
\"body\": $BODY_JSON,
\"draft\": false,
\"prerelease\": $IS_PRE
}")
# Fallback: if release already exists for this tag, reuse it
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
if [ -z "$RELEASE_ID" ]; then
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"
build-docker:
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
echo "DOCKER_REDEPLOY_WEBHOOK_URL not set — skipping auto-deploy"
fi
+2 -2
View File
@@ -4,6 +4,6 @@ web/build/
web/.svelte-kit/ web/.svelte-kit/
data/ data/
.env .env
docker-watcher tinyforge
docker-watcher.exe tinyforge.exe
server.exe server.exe
+1 -1
View File
@@ -1,4 +1,4 @@
# Docker Watcher # Tinyforge
## Dev Server ## Dev Server
+3 -3
View File
@@ -22,7 +22,7 @@ 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 /docker-watcher ./cmd/server RUN 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
@@ -34,7 +34,7 @@ RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app
WORKDIR /app WORKDIR /app
COPY --from=backend-builder /docker-watcher /app/docker-watcher COPY --from=backend-builder /tinyforge /app/tinyforge
# Data directory for SQLite database. # Data directory for SQLite database.
RUN mkdir -p /app/data && chown -R app:app /app RUN mkdir -p /app/data && chown -R app:app /app
@@ -46,4 +46,4 @@ EXPOSE 8080
ENV DATA_DIR=/app/data ENV DATA_DIR=/app/data
ENV LISTEN_ADDR=:8080 ENV LISTEN_ADDR=:8080
ENTRYPOINT ["/app/docker-watcher"] ENTRYPOINT ["/app/tinyforge"]
+51
View File
@@ -0,0 +1,51 @@
# Mini CI Feature Ideas
Feature ideas for evolving the project from a Docker container watcher into a self-hosted mini CI/deployment platform for local developers.
## Name Candidates
| Name | Vibe | Domain feel |
|---|---|---|
| **Shipyard** | Where you build and launch ships (deployments). Nautical, memorable. | `shipyard.dev` |
| **Dockside** | Nods to Docker heritage, but broader — "the place beside the dock." | `dockside.dev` |
| **Launchpad** | CI/CD connotation, action-oriented. | `launchpad.run` |
| **Portside** | Same nautical lane as Portainer, but fresh. | `portside.dev` |
| **Homeport** | Self-hosted feel, "home" + "port" (Docker). | `homeport.dev` |
| **Tinyforge** | Small but powerful — a forge for building/deploying. | `tinyforge.dev` |
| **Deployr** | Blunt, says exactly what it does. | `deployr.dev` |
| **Runwell** | "Run things well." Simple, positive. | `runwell.dev` |
## Build Pipeline
- **Build from source** — clone a repo, run a `Dockerfile` or `docker-compose.yml`, build the image locally, then deploy it. Closes the loop from source to running container.
- **Build logs streaming** — SSE stream of `docker build` output, reusing the existing container logs streaming pattern.
- **Build cache management** — show Docker layer cache stats, allow selective cache invalidation.
## Git Integration
- **Webhook receiver for push events** — Gitea/GitHub/GitLab sends a push webhook, the platform rebuilds and redeploys automatically. Reuses existing webhook infra from registry polling.
- **Branch preview environments** — push to `feature/foo`, get a temporary deployment at `foo.preview.local`. Auto-cleanup when the branch is deleted.
- **Commit status reporting** — push deploy status back to Gitea/GitHub as commit statuses (green check / red X).
## Developer Experience
- **CLI tool** — `shipyard deploy`, `shipyard logs`, `shipyard status` from the terminal for developers who prefer the shell.
- **`.shipyard.yml` project config** — a declarative file in the repo root that defines how to build, which env vars to inject, health check paths, proxy rules. One file, full deploy config.
- **Environment promotion** — one-click promote from `dev` to `staging` to `prod`. Builds on the existing multi-stage project model by adding a promotion workflow.
## Observability
- **Resource dashboard** — CPU/memory/disk per container over time (not just a snapshot). Use Docker stats API with a small ring buffer in SQLite.
- **Deploy timeline** — a visual timeline showing deploys, rollbacks, and incidents across all projects. "What happened in my infra today?"
- **Alerting** — container OOM, high CPU, health check failures pushed to Telegram/Discord/email/webhook.
## Multi-Service Orchestration
- **Compose support** — import a `docker-compose.yml` and manage the entire stack as one project. Deploy/rollback the stack atomically.
- **Service dependency graph** — visualize which services depend on which. Block deploys if a dependency is unhealthy.
- **Shared secrets** — secrets scoped to a project or global, injected into any service that needs them. Extends the existing encrypted secrets model from static sites.
## Database / Persistence
- **Database snapshots** — one-click snapshot/restore of database volumes before risky deploys.
- **Automatic pre-deploy backup** — snapshot the data volume before every deploy, auto-prune old snapshots.
+2 -2
View File
@@ -9,7 +9,7 @@ build-frontend:
# Build the Go binary (embeds web/build/ via go:embed). # Build the Go binary (embeds web/build/ via go:embed).
build-backend: build-backend:
go build -o docker-watcher ./cmd/server go build -o tinyforge ./cmd/server
# Run in development mode with hot reload. # Run in development mode with hot reload.
# Requires air (go install github.com/air-verse/air@latest). # Requires air (go install github.com/air-verse/air@latest).
@@ -18,4 +18,4 @@ dev:
# Clean build artifacts. # Clean build artifacts.
clean: clean:
rm -rf web/build web/node_modules/.vite docker-watcher rm -rf web/build web/node_modules/.vite tinyforge
+39 -10
View File
@@ -1,17 +1,44 @@
# Docker Watcher # Tinyforge
Automated Docker deployment orchestrator with a web dashboard. Watches container registries for new image tags and deploys them with zero-downtime blue-green strategy, health checks, and automatic NPM (Nginx Proxy Manager) proxy configuration. Self-hosted deployment platform with a web dashboard. Deploy Docker containers from registries with zero-downtime blue-green strategy, host static sites and Deno APIs directly from Git repositories, and manage reverse proxy configuration — all from a single binary.
## Features ## Features
### Container Deployments
- **Registry polling** and **webhook receiver** for automatic deployments - **Registry polling** and **webhook receiver** for automatic deployments
- **Blue-green deploys** with health checks and automatic rollback - **Blue-green deploys** with health checks and automatic rollback
- **NPM integration** for automatic reverse proxy configuration
- **Multi-stage projects** (dev, staging, prod) with tag pattern matching - **Multi-stage projects** (dev, staging, prod) with tag pattern matching
- **Real-time deploy logs** via SSE streaming - **Real-time deploy logs** via SSE streaming
- **OIDC/SSO support** alongside local auth
### Static Sites
Deploy static sites and Deno-powered APIs directly from Git repositories:
- **Git providers**: Gitea/Forgejo, GitHub, and GitLab (public and private repos)
- **Static mode**: Serves HTML/CSS/JS via nginx container
- **Deno mode**: Full-stack with TypeScript API backend + static frontend — API routes are auto-discovered from `/api` folder using a naming convention (`API_get_users`, `API_post_items`, etc.)
- **Markdown rendering**: Optionally converts `.md` files to styled HTML
- **Branch & folder picker**: Select any branch and subfolder as the deployment root
- **Auto-sync**: Trigger redeployment on push or tag events, or manually
- **Per-site secrets**: Encrypted environment variables injected at runtime
### Infrastructure
- **NPM / Traefik integration** for automatic reverse proxy and SSL configuration
- **Cloudflare DNS** sync for automatic DNS record management
- **Volume management**: Create, browse, upload, and download Docker volumes
- **Stale container cleanup**: Detect and remove unused containers
- **Image management**: List and prune unused Docker images
- **Database backups**: Scheduled and manual backups with one-click restore
- **Config export/import**: YAML-based seed configuration for reproducible setups
### Auth & Security
- **Local auth** with bcrypt password hashing
- **OIDC/SSO** support for single sign-on
- **Encrypted credential storage** (AES-256-GCM) - **Encrypted credential storage** (AES-256-GCM)
- **Single binary** with embedded SPA frontend - **Role-based access**: Admin and user roles
## Prerequisites ## Prerequisites
@@ -36,7 +63,7 @@ Automated Docker deployment orchestrator with a web dashboard. Watches container
# Generate a key: openssl rand -hex 32 # Generate a key: openssl rand -hex 32
``` ```
3. **Start Docker Watcher**: 3. **Start Tinyforge**:
```bash ```bash
docker compose up -d docker compose up -d
@@ -49,10 +76,10 @@ Automated Docker deployment orchestrator with a web dashboard. Watches container
### Environment Variables ### Environment Variables
| Variable | Required | Description | | Variable | Required | Description |
|----------|----------|-------------| | ------------------ | ------------------- | -------------------------------------------------------------------------------- |
| `ENCRYPTION_KEY` | Yes | AES-256 key for encrypting stored credentials. Use `openssl rand -hex 32` | | `ENCRYPTION_KEY` | Yes | AES-256 key for encrypting stored credentials. Use `openssl rand -hex 32` |
| `ADMIN_PASSWORD` | Yes (first launch) | Password for the default admin user | | `ADMIN_PASSWORD` | Yes (first launch) | Password for the default admin user |
| `SEED_FILE` | No | Path to YAML seed config (default: `./docker-watcher.yaml`) | | `SEED_FILE` | No | Path to YAML seed config (default: `./tinyforge.yaml`) |
| `DATA_DIR` | No | SQLite database directory (default: `./data`) | | `DATA_DIR` | No | SQLite database directory (default: `./data`) |
| `LISTEN_ADDR` | No | HTTP listen address (default: `:8080`) | | `LISTEN_ADDR` | No | HTTP listen address (default: `:8080`) |
| `NPM_URL` | No | Override NPM API URL (otherwise uses value from settings) | | `NPM_URL` | No | Override NPM API URL (otherwise uses value from settings) |
@@ -60,7 +87,7 @@ Automated Docker deployment orchestrator with a web dashboard. Watches container
### Seed Config ### Seed Config
On first launch, Docker Watcher imports a YAML seed file to pre-configure registries, projects, and settings. See `docker-watcher.example.yaml` for the full format. On first launch, Tinyforge imports a YAML seed file to pre-configure registries, projects, and settings. See `tinyforge.example.yaml` for the full format.
### Webhook Integration ### Webhook Integration
@@ -95,9 +122,11 @@ make dev
## Architecture ## Architecture
``` ```text
CI/Registry --> Webhook/Poller --> Deployer --> Docker + NPM CI/Registry --> Webhook/Poller --> Deployer --> Docker + NPM
| |
Git Repo ----> Static Sites -------> Docker + NPM
|
Event Bus --> SSE --> Web Dashboard Event Bus --> SSE --> Web Dashboard
``` ```
+25 -25
View File
@@ -15,26 +15,26 @@ import (
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
dockerwatcher "github.com/alexei/docker-watcher" tinyforge "github.com/alexei/tinyforge"
"github.com/alexei/docker-watcher/internal/api" "github.com/alexei/tinyforge/internal/api"
"github.com/alexei/docker-watcher/internal/auth" "github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/docker-watcher/internal/config" "github.com/alexei/tinyforge/internal/config"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/backup" "github.com/alexei/tinyforge/internal/backup"
"github.com/alexei/docker-watcher/internal/deployer" "github.com/alexei/tinyforge/internal/deployer"
"github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/tinyforge/internal/dns"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/events" "github.com/alexei/tinyforge/internal/events"
"github.com/alexei/docker-watcher/internal/health" "github.com/alexei/tinyforge/internal/health"
"github.com/alexei/docker-watcher/internal/logging" "github.com/alexei/tinyforge/internal/logging"
"github.com/alexei/docker-watcher/internal/notify" "github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/tinyforge/internal/npm"
"github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/docker-watcher/internal/registry" "github.com/alexei/tinyforge/internal/registry"
"github.com/alexei/docker-watcher/internal/stale" "github.com/alexei/tinyforge/internal/stale"
"github.com/alexei/docker-watcher/internal/staticsite" "github.com/alexei/tinyforge/internal/staticsite"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/alexei/docker-watcher/internal/webhook" "github.com/alexei/tinyforge/internal/webhook"
) )
func main() { func main() {
@@ -49,7 +49,7 @@ func main() {
} }
// Open database. // Open database.
dbPath := filepath.Join(dataDir, "docker-watcher.db") dbPath := filepath.Join(dataDir, "tinyforge.db")
db, err := store.New(dbPath) db, err := store.New(dbPath)
if err != nil { if err != nil {
slog.Error("open store", "error", err) slog.Error("open store", "error", err)
@@ -65,7 +65,7 @@ func main() {
} }
// Import seed config on first launch (idempotent). // Import seed config on first launch (idempotent).
seedPath := envOrDefault("SEED_FILE", "./docker-watcher.yaml") seedPath := envOrDefault("SEED_FILE", "./tinyforge.yaml")
if err := config.ImportSeed(db, seedPath); err != nil { if err := config.ImportSeed(db, seedPath); err != nil {
slog.Error("seed import", "error", err) slog.Error("seed import", "error", err)
os.Exit(1) os.Exit(1)
@@ -306,7 +306,7 @@ func main() {
// Serve embedded static files for the SPA frontend. // Serve embedded static files for the SPA frontend.
// The embed.FS has "web/build" as a prefix, so we sub it to get the root. // The embed.FS has "web/build" as a prefix, so we sub it to get the root.
webBuildFS, err := fs.Sub(dockerwatcher.WebBuildFS, "web/build") webBuildFS, err := fs.Sub(tinyforge.WebBuildFS, "web/build")
if err != nil { if err != nil {
slog.Warn("embedded frontend not available", "error", err) slog.Warn("embedded frontend not available", "error", err)
} else { } else {
@@ -337,7 +337,7 @@ func main() {
}) })
go func() { go func() {
slog.Info("Docker Watcher started", "addr", addr) slog.Info("Tinyforge started", "addr", addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("HTTP server error", "error", err) slog.Error("HTTP server error", "error", err)
os.Exit(1) os.Exit(1)
@@ -371,7 +371,7 @@ func main() {
slog.Error("database close error", "error", err) slog.Error("database close error", "error", err)
} }
slog.Info("Docker Watcher stopped") slog.Info("Tinyforge stopped")
} }
// envOrDefault reads an environment variable or returns the fallback value. // envOrDefault reads an environment variable or returns the fallback value.
+7 -7
View File
@@ -1,8 +1,8 @@
services: services:
docker-watcher: tinyforge:
build: . build: .
image: docker-watcher:latest image: tinyforge:latest
container_name: docker-watcher container_name: tinyforge
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
@@ -10,16 +10,16 @@ services:
# Mount Docker socket for container management. # Mount Docker socket for container management.
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
# Persistent data (SQLite database). # Persistent data (SQLite database).
- docker-watcher-data:/app/data - tinyforge-data:/app/data
# Optional seed config (read on first launch only). # Optional seed config (read on first launch only).
- ./docker-watcher.yaml:/app/docker-watcher.yaml:ro - ./tinyforge.yaml:/app/tinyforge.yaml:ro
environment: environment:
# Required: protects all credentials stored in the database. # Required: protects all credentials stored in the database.
- ENCRYPTION_KEY=${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env} - ENCRYPTION_KEY=${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env}
# Required on first launch: password for the default admin user. # Required on first launch: password for the default admin user.
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env} - ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
# Optional: override seed file location. # Optional: override seed file location.
- SEED_FILE=/app/docker-watcher.yaml - SEED_FILE=/app/tinyforge.yaml
# Optional: override data directory. # Optional: override data directory.
- DATA_DIR=/app/data - DATA_DIR=/app/data
# Optional: override listen address. # Optional: override listen address.
@@ -38,7 +38,7 @@ services:
start_period: 10s start_period: 10s
volumes: volumes:
docker-watcher-data: tinyforge-data:
driver: local driver: local
# NOTE: The staging-net network must exist before starting. # NOTE: The staging-net network must exist before starting.
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/alexei/docker-watcher module github.com/alexei/tinyforge
go 1.24.0 go 1.24.0
+3 -3
View File
@@ -10,9 +10,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/auth" "github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// rateLimitedLogin wraps the login handler with per-IP rate limiting. // rateLimitedLogin wraps the login handler with per-IP rate limiting.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
+2 -2
View File
@@ -4,7 +4,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/alexei/docker-watcher/internal/config" "github.com/alexei/tinyforge/internal/config"
) )
// exportConfig handles GET /api/config/export — downloads current state as YAML. // exportConfig handles GET /api/config/export — downloads current state as YAML.
@@ -17,7 +17,7 @@ func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "application/x-yaml") w.Header().Set("Content-Type", "application/x-yaml")
w.Header().Set("Content-Disposition", "attachment; filename=docker-watcher.yaml") w.Header().Set("Content-Disposition", "attachment; filename=tinyforge.yaml")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write(data) w.Write(data)
} }
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// listDeploys handles GET /api/deploys. // listDeploys handles GET /api/deploys.
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/tinyforge/internal/dns"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// listProjectImages handles GET /api/projects/{id}/images. // listProjectImages handles GET /api/projects/{id}/images.
@@ -220,7 +220,7 @@ func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
} }
// pruneImages handles POST /api/docker/prune-images. // pruneImages handles POST /api/docker/prune-images.
// Only removes images that belong to Docker Watcher projects (not all system images). // Only removes images that belong to Tinyforge projects (not all system images).
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) { func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
if s.docker == nil { if s.docker == nil {
respondError(w, http.StatusServiceUnavailable, "Docker is not available") respondError(w, http.StatusServiceUnavailable, "Docker is not available")
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// listEventLog handles GET /api/events/log. // listEventLog handles GET /api/events/log.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// listInstances handles GET /api/projects/{id}/stages/{stage}/instances. // listInstances handles GET /api/projects/{id}/stages/{stage}/instances.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/events" "github.com/alexei/tinyforge/internal/events"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// projectRequest is the expected JSON body for creating/updating a project. // projectRequest is the expected JSON body for creating/updating a project.
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/registry" "github.com/alexei/tinyforge/internal/registry"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// registryRequest is the expected JSON body for creating/updating a registry. // registryRequest is the expected JSON body for creating/updating a registry.
+12 -12
View File
@@ -7,18 +7,18 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/auth" "github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/docker-watcher/internal/backup" "github.com/alexei/tinyforge/internal/backup"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/tinyforge/internal/dns"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/events" "github.com/alexei/tinyforge/internal/events"
"github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/tinyforge/internal/npm"
"github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/docker-watcher/internal/stale" "github.com/alexei/tinyforge/internal/stale"
"github.com/alexei/docker-watcher/internal/staticsite" "github.com/alexei/tinyforge/internal/staticsite"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/alexei/docker-watcher/internal/webhook" "github.com/alexei/tinyforge/internal/webhook"
) )
// DNSProviderChangedFunc is called when DNS settings change so the caller can // DNSProviderChangedFunc is called when DNS settings change so the caller can
+8 -8
View File
@@ -7,14 +7,14 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/tinyforge/internal/dns"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/tinyforge/internal/npm"
"github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/alexei/docker-watcher/internal/volume" "github.com/alexei/tinyforge/internal/volume"
"github.com/alexei/docker-watcher/internal/webhook" "github.com/alexei/tinyforge/internal/webhook"
) )
// settingsRequest is the expected JSON body for updating settings. // settingsRequest is the expected JSON body for updating settings.
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/events" "github.com/alexei/tinyforge/internal/events"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// streamDeployLogs handles GET /api/deploys/{id}/logs. // streamDeployLogs handles GET /api/deploys/{id}/logs.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// stageEnvRequest is the expected JSON body for creating/updating a stage env override. // stageEnvRequest is the expected JSON body for creating/updating a stage env override.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/events" "github.com/alexei/tinyforge/internal/events"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// stageRequest is the expected JSON body for creating/updating a stage. // stageRequest is the expected JSON body for creating/updating a stage.
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/events" "github.com/alexei/tinyforge/internal/events"
"github.com/alexei/docker-watcher/internal/stale" "github.com/alexei/tinyforge/internal/stale"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// listStaleContainers handles GET /api/containers/stale. // listStaleContainers handles GET /api/containers/stale.
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// ── List / Get ───────────────────────────────────────────────────────── // ── List / Get ─────────────────────────────────────────────────────────
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// getInstanceStats handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/stats. // getInstanceStats handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/stats.
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/alexei/docker-watcher/internal/volume" "github.com/alexei/tinyforge/internal/volume"
) )
// sanitizeFilename removes characters unsafe for Content-Disposition headers. // sanitizeFilename removes characters unsafe for Content-Disposition headers.
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/alexei/docker-watcher/internal/volume" "github.com/alexei/tinyforge/internal/volume"
) )
// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot. // safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot.
+2 -2
View File
@@ -41,7 +41,7 @@ type LocalAuth struct {
// using HMAC-SHA256. // using HMAC-SHA256.
func NewLocalAuth(encKey [32]byte) *LocalAuth { func NewLocalAuth(encKey [32]byte) *LocalAuth {
mac := hmac.New(sha256.New, encKey[:]) mac := hmac.New(sha256.New, encKey[:])
mac.Write([]byte("docker-watcher-jwt-secret")) mac.Write([]byte("tinyforge-jwt-secret"))
la := &LocalAuth{ la := &LocalAuth{
jwtSecret: mac.Sum(nil), jwtSecret: mac.Sum(nil),
blacklist: make(map[string]time.Time), blacklist: make(map[string]time.Time),
@@ -110,7 +110,7 @@ func (la *LocalAuth) GenerateToken(claims Claims) (SessionToken, error) {
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt), ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "docker-watcher", Issuer: "tinyforge",
}, },
UserID: claims.UserID, UserID: claims.UserID,
Username: claims.Username, Username: claims.Username,
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// Engine manages database backup operations. // Engine manages database backup operations.
@@ -52,7 +52,7 @@ func (e *Engine) CreateBackup(backupType string) (store.Backup, error) {
defer e.mu.Unlock() defer e.mu.Unlock()
timestamp := time.Now().UTC().Format("20060102-150405") timestamp := time.Now().UTC().Format("20060102-150405")
filename := fmt.Sprintf("docker-watcher-%s-%s.db", backupType, timestamp) filename := fmt.Sprintf("tinyforge-%s-%s.db", backupType, timestamp)
destPath := filepath.Join(e.backupDir, filename) destPath := filepath.Join(e.backupDir, filename)
// VACUUM INTO creates a clean, standalone copy of the database. // VACUUM INTO creates a clean, standalone copy of the database.
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"log/slog" "log/slog"
"os" "os"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/google/uuid" "github.com/google/uuid"
) )
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/google/uuid" "github.com/google/uuid"
) )
+9 -9
View File
@@ -9,15 +9,15 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/tinyforge/internal/dns"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/events" "github.com/alexei/tinyforge/internal/events"
"github.com/alexei/docker-watcher/internal/health" "github.com/alexei/tinyforge/internal/health"
"github.com/alexei/docker-watcher/internal/notify" "github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/alexei/docker-watcher/internal/volume" "github.com/alexei/tinyforge/internal/volume"
"github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/mount"
"github.com/google/uuid" "github.com/google/uuid"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package deployer
import ( import (
"fmt" "fmt"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// validatePromoteFrom checks that a tag is running in the promote_from stage // validatePromoteFrom checks that a tag is running in the promote_from stage
+4 -4
View File
@@ -7,11 +7,11 @@ import (
"github.com/moby/moby/client" "github.com/moby/moby/client"
) )
// Labels applied to all containers managed by docker-watcher. // Labels applied to all containers managed by Tinyforge.
const ( const (
LabelProject = "docker-watcher.project" LabelProject = "tinyforge.project"
LabelStage = "docker-watcher.stage" LabelStage = "tinyforge.stage"
LabelInstanceID = "docker-watcher.instance-id" LabelInstanceID = "tinyforge.instance-id"
) )
// Client wraps the Docker Engine API client. // Client wraps the Docker Engine API client.
+8 -8
View File
@@ -36,16 +36,16 @@ type ContainerConfig struct {
NetworkID string NetworkID string
// Labels are additional labels to apply to the container. // Labels are additional labels to apply to the container.
// docker-watcher management labels are added automatically via Project, Stage, and InstanceID. // Tinyforge management labels are added automatically via Project, Stage, and InstanceID.
Labels map[string]string Labels map[string]string
// Project is the docker-watcher project name (used for labelling). // Project is the Tinyforge project name (used for labelling).
Project string Project string
// Stage is the docker-watcher stage name (used for labelling). // Stage is the Tinyforge stage name (used for labelling).
Stage string Stage string
// InstanceID is the docker-watcher instance ID (used for labelling). // InstanceID is the Tinyforge instance ID (used for labelling).
InstanceID string InstanceID string
// Mounts is a list of bind mounts to attach to the container. // Mounts is a list of bind mounts to attach to the container.
@@ -88,7 +88,7 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri
} }
} }
// Merge docker-watcher labels with any additional labels. // Merge Tinyforge labels with any additional labels.
labels := make(map[string]string) labels := make(map[string]string)
for k, v := range cfg.Labels { for k, v := range cfg.Labels {
labels[k] = v labels[k] = v
@@ -198,7 +198,7 @@ func (c *Client) RestartContainer(ctx context.Context, containerID string, timeo
return nil return nil
} }
// ManagedContainer holds summary information about a container managed by docker-watcher. // ManagedContainer holds summary information about a container managed by Tinyforge.
type ManagedContainer struct { type ManagedContainer struct {
ID string ID string
Name string Name string
@@ -212,12 +212,12 @@ type ManagedContainer struct {
} }
// ListContainers returns all containers matching the given label filters. // ListContainers returns all containers matching the given label filters.
// Pass nil or an empty map to list all docker-watcher managed containers. // Pass nil or an empty map to list all Tinyforge managed containers.
// Label filters are key=value pairs applied as Docker label filters. // Label filters are key=value pairs applied as Docker label filters.
func (c *Client) ListContainers(ctx context.Context, labelFilters map[string]string) ([]ManagedContainer, error) { func (c *Client) ListContainers(ctx context.Context, labelFilters map[string]string) ([]ManagedContainer, error) {
filterArgs := make(client.Filters) filterArgs := make(client.Filters)
// Always filter by the docker-watcher project label to only return managed containers. // Always filter by the Tinyforge project label to only return managed containers.
filterArgs.Add("label", LabelProject) filterArgs.Add("label", LabelProject)
for k, v := range labelFilters { for k, v := range labelFilters {
+1 -1
View File
@@ -32,7 +32,7 @@ func (c *Client) EnsureNetwork(ctx context.Context, networkName string) (string,
resp, err := c.api.NetworkCreate(ctx, networkName, client.NetworkCreateOptions{ resp, err := c.api.NetworkCreate(ctx, networkName, client.NetworkCreateOptions{
Driver: "bridge", Driver: "bridge",
Labels: map[string]string{ Labels: map[string]string{
LabelProject: "docker-watcher", LabelProject: "tinyforge",
}, },
}) })
if err != nil { if err != nil {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/tinyforge/internal/npm"
) )
// NpmProvider wraps the NPM client behind the Provider interface. // NpmProvider wraps the NPM client behind the Provider interface.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
) )
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"sync" "sync"
"time" "time"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/events" "github.com/alexei/tinyforge/internal/events"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
) )
+1 -1
View File
@@ -126,7 +126,7 @@ func parseAPIFunctionName(funcName, baseRoute, importPath string) (RouteEntry, b
} }
// routerTemplate is the Deno router entrypoint template. // routerTemplate is the Deno router entrypoint template.
var routerTemplate = template.Must(template.New("router").Parse(`// Auto-generated by Docker Watcher — do not edit manually. var routerTemplate = template.Must(template.New("router").Parse(`// Auto-generated by Tinyforge — do not edit manually.
import { serveDir } from "https://deno.land/std/http/file_server.ts"; import { serveDir } from "https://deno.land/std/http/file_server.ts";
{{- range .Imports}} {{- range .Imports}}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
) )
+10 -10
View File
@@ -10,12 +10,12 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/events" "github.com/alexei/tinyforge/internal/events"
"github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/docker-watcher/internal/staticsite/deno" "github.com/alexei/tinyforge/internal/staticsite/deno"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// Manager orchestrates the static site deployment pipeline. // Manager orchestrates the static site deployment pipeline.
@@ -207,8 +207,8 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
NetworkName: networkName, NetworkName: networkName,
NetworkID: networkID, NetworkID: networkID,
Labels: map[string]string{ Labels: map[string]string{
"docker-watcher.static-site": site.ID, "tinyforge.static-site": site.ID,
"docker-watcher.static-site-name": site.Name, "tinyforge.static-site-name": site.Name,
}, },
Project: "static-site", Project: "static-site",
Stage: site.Name, Stage: site.Name,
@@ -230,8 +230,8 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
NetworkName: networkName, NetworkName: networkName,
NetworkID: networkID, NetworkID: networkID,
Labels: map[string]string{ Labels: map[string]string{
"docker-watcher.static-site": site.ID, "tinyforge.static-site": site.ID,
"docker-watcher.static-site-name": site.Name, "tinyforge.static-site-name": site.Name,
}, },
Project: "static-site", Project: "static-site",
Stage: site.Name, Stage: site.Name,
+2 -2
View File
@@ -110,7 +110,7 @@ func (s *Store) runMigrations() error {
`ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`, `ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`, `ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
// Set default network for existing databases with empty network. // Set default network for existing databases with empty network.
`UPDATE settings SET network = 'docker-watcher' WHERE network = ''`, `UPDATE settings SET network = 'tinyforge' WHERE network = ''`,
// NPM remote mode: forward to server_ip instead of container name. // NPM remote mode: forward to server_ip instead of container name.
`ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`,
// Resource limits per stage. // Resource limits per stage.
@@ -219,7 +219,7 @@ CREATE TABLE IF NOT EXISTS settings (
domain TEXT NOT NULL DEFAULT '', domain TEXT NOT NULL DEFAULT '',
server_ip TEXT NOT NULL DEFAULT '', server_ip TEXT NOT NULL DEFAULT '',
public_ip TEXT NOT NULL DEFAULT '', public_ip TEXT NOT NULL DEFAULT '',
network TEXT NOT NULL DEFAULT 'docker-watcher', network TEXT NOT NULL DEFAULT 'tinyforge',
subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}', subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}',
notification_url TEXT NOT NULL DEFAULT '', notification_url TEXT NOT NULL DEFAULT '',
npm_url TEXT NOT NULL DEFAULT '', npm_url TEXT NOT NULL DEFAULT '',
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// ResolveParams holds the parameters needed to resolve a volume's host path. // ResolveParams holds the parameters needed to resolve a volume's host path.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"log/slog" "log/slog"
"strings" "strings"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// AutoCreateProject creates a new project and a default "dev" stage from an // AutoCreateProject creates a new project and a default "dev" stage from an
+2 -2
View File
@@ -12,8 +12,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// DeployTriggerer is called when a webhook determines a deploy should happen. // DeployTriggerer is called when a webhook determines a deploy should happen.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"path" "path"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/tinyforge/internal/store"
) )
// FindProjectAndStage searches for a project whose image matches the parsed // FindProjectAndStage searches for a project whose image matches the parsed
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Start (or restart) the Docker Watcher dev server on port 8090. # Start (or restart) the Tinyforge dev server on port 8090.
# Usage: ./scripts/dev-server.sh # Usage: ./scripts/dev-server.sh
set -euo pipefail set -euo pipefail
@@ -32,6 +32,6 @@ export ENCRYPTION_KEY
export ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}" export ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}"
export LISTEN_ADDR="${PORT}" export LISTEN_ADDR="${PORT}"
echo "Starting Docker Watcher on http://localhost:${PORT_NUM}" echo "Starting Tinyforge on http://localhost:${PORT_NUM}"
echo "Login: admin / ${ADMIN_PASSWORD}" echo "Login: admin / ${ADMIN_PASSWORD}"
exec go run ./cmd/server exec go run ./cmd/server
@@ -1,11 +1,11 @@
# Docker Watcher — Seed Configuration # Tinyforge — Seed Configuration
# #
# This file is read ONCE on first launch to populate the SQLite database. # This file is read ONCE on first launch to populate the SQLite database.
# After import, all configuration is managed via the Web UI. # After import, all configuration is managed via the Web UI.
# The only required env var is ENCRYPTION_KEY (used to encrypt credentials in DB). # The only required env var is ENCRYPTION_KEY (used to encrypt credentials in DB).
# #
# Place this file as ./docker-watcher.yaml (or set SEED_FILE env var) # Place this file as ./tinyforge.yaml (or set SEED_FILE env var)
# and start Docker Watcher. Once imported, the file is never read again. # and start Tinyforge. Once imported, the file is never read again.
global: global:
# Your base domain — must have a Cloudflare wildcard DNS record (*.domain) # Your base domain — must have a Cloudflare wildcard DNS record (*.domain)
+1 -1
View File
@@ -1,4 +1,4 @@
package dockerwatcher package tinyforge
import "embed" import "embed"
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "docker-watcher-web", "name": "tinyforge-web",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "docker-watcher-web", "name": "tinyforge-web",
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "docker-watcher-web", "name": "tinyforge-web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
+31 -7
View File
@@ -25,6 +25,29 @@
let logContainer: HTMLDivElement | undefined = $state(); let logContainer: HTMLDivElement | undefined = $state();
let eventSource: EventSource | null = null; let eventSource: EventSource | null = null;
// Batch incoming SSE log lines to avoid per-line re-renders.
let pendingLines: string[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
function flushPendingLines() {
flushTimer = null;
if (pendingLines.length === 0) return;
let updated = [...lines, ...pendingLines];
pendingLines = [];
if (updated.length > tailCount * 2) {
updated = updated.slice(-tailCount);
}
lines = updated;
scrollToBottom();
}
function enqueueLine(line: string) {
pendingLines.push(line);
if (!flushTimer) {
flushTimer = setTimeout(flushPendingLines, 150);
}
}
async function loadLogs() { async function loadLogs() {
loading = true; loading = true;
error = ''; error = '';
@@ -49,12 +72,7 @@
try { try {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
if (data.line) { if (data.line) {
lines = [...lines, data.line]; enqueueLine(data.line);
// Trim to max lines.
if (lines.length > tailCount * 2) {
lines = lines.slice(-tailCount);
}
scrollToBottom();
} }
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
}; };
@@ -69,6 +87,9 @@
eventSource.close(); eventSource.close();
eventSource = null; eventSource = null;
} }
// Flush any buffered lines before stopping.
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushPendingLines();
following = false; following = false;
} }
@@ -90,7 +111,10 @@
// Load on mount. // Load on mount.
$effect(() => { loadLogs(); }); $effect(() => { loadLogs(); });
onDestroy(() => { stopFollowing(); }); onDestroy(() => {
stopFollowing();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
});
</script> </script>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-page)] shadow-lg"> <div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-page)] shadow-lg">
+7 -2
View File
@@ -19,8 +19,11 @@
$effect(() => { $effect(() => {
let cancelled = false; let cancelled = false;
let inflight = false;
async function load() { async function load() {
if (inflight) return; // Skip if previous request still pending.
inflight = true;
try { try {
const result = await api.fetchContainerStats(projectId, stageId, instanceId); const result = await api.fetchContainerStats(projectId, stageId, instanceId);
if (!cancelled) { if (!cancelled) {
@@ -31,13 +34,15 @@
if (!cancelled) { if (!cancelled) {
error = true; error = true;
} }
} finally {
inflight = false;
} }
} }
load(); load();
// Poll every 10 seconds. // Poll every 30 seconds (reduced from 10s to limit concurrent connections).
const interval = setInterval(load, 10_000); const interval = setInterval(load, 30_000);
return () => { return () => {
cancelled = true; cancelled = true;
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Lucide-based SVG icon components for Docker Watcher. * Lucide-based SVG icon components for Tinyforge.
* Task 2: Inline SVGs from Lucide icon set as Svelte components. * Task 2: Inline SVGs from Lucide icon set as Svelte components.
* *
* Each icon is a standalone .svelte component accepting size and class props. * Each icon is a standalone .svelte component accepting size and class props.
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"app": { "app": {
"name": "Docker Watcher", "name": "Tinyforge",
"version": "v0.1" "version": "v0.1"
}, },
"health": { "health": {
@@ -508,7 +508,7 @@
"password": "Password" "password": "Password"
}, },
"login": { "login": {
"title": "Docker Watcher", "title": "Tinyforge",
"subtitle": "Sign in to your account", "subtitle": "Sign in to your account",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
@@ -819,7 +819,7 @@
}, },
"dns": { "dns": {
"title": "DNS Records", "title": "DNS Records",
"description": "View and manage DNS records created by Docker Watcher.", "description": "View and manage DNS records created by Tinyforge.",
"wildcardActive": "Wildcard DNS Mode Active", "wildcardActive": "Wildcard DNS Mode Active",
"wildcardActiveDesc": "DNS records are managed externally via wildcard DNS. Disable wildcard DNS in Settings to manage records individually.", "wildcardActiveDesc": "DNS records are managed externally via wildcard DNS. Disable wildcard DNS in Settings to manage records individually.",
"refresh": "Refresh", "refresh": "Refresh",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"app": { "app": {
"name": "Docker Watcher", "name": "Tinyforge",
"version": "v0.1" "version": "v0.1"
}, },
"health": { "health": {
@@ -508,7 +508,7 @@
"password": "Пароль" "password": "Пароль"
}, },
"login": { "login": {
"title": "Docker Watcher", "title": "Tinyforge",
"subtitle": "Войдите в свой аккаунт", "subtitle": "Войдите в свой аккаунт",
"username": "Имя пользователя", "username": "Имя пользователя",
"password": "Пароль", "password": "Пароль",
@@ -819,7 +819,7 @@
}, },
"dns": { "dns": {
"title": "DNS-записи", "title": "DNS-записи",
"description": "Просмотр и управление DNS-записями, созданными Docker Watcher.", "description": "Просмотр и управление DNS-записями, созданными Tinyforge.",
"wildcardActive": "Режим Wildcard DNS активен", "wildcardActive": "Режим Wildcard DNS активен",
"wildcardActiveDesc": "DNS-записи управляются внешне через wildcard DNS. Отключите wildcard DNS в настройках для индивидуального управления записями.", "wildcardActiveDesc": "DNS-записи управляются внешне через wildcard DNS. Отключите wildcard DNS в настройках для индивидуального управления записями.",
"refresh": "Обновить", "refresh": "Обновить",
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* SSE client helper with auto-reconnect and exponential backoff. * SSE client helper with auto-reconnect and exponential backoff.
* *
* Provides type-safe event handling for Docker Watcher's real-time * Provides type-safe event handling for Tinyforge's real-time
* event streams (deploy logs and instance status changes). * event streams (deploy logs and instance status changes).
*/ */
+23
View File
@@ -0,0 +1,23 @@
/**
* Simple pub/sub bus for SSE event_log payloads.
*
* The layout component publishes events from the single global SSE connection.
* Pages (e.g. /events) subscribe without opening a duplicate SSE connection.
*/
import type { EventLogSSEPayload } from '$lib/sse';
type Listener = (payload: EventLogSSEPayload) => void;
const listeners = new Set<Listener>();
export function subscribeEventLog(fn: Listener): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
export function publishEventLog(payload: EventLogSSEPayload): void {
for (const fn of listeners) {
fn(payload);
}
}
+7 -7
View File
@@ -8,7 +8,8 @@
import { fetchEventLog, fetchEventLogStats, clearAllEvents, deleteEvent } from '$lib/api'; import { fetchEventLog, fetchEventLogStats, clearAllEvents, deleteEvent } from '$lib/api';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { connectGlobalEvents, type SSEConnection, type EventLogSSEPayload } from '$lib/sse'; import { subscribeEventLog } from '$lib/stores/event-log-bus';
import type { EventLogSSEPayload } from '$lib/sse';
import type { EventLogEntry, EventLogStats } from '$lib/types'; import type { EventLogEntry, EventLogStats } from '$lib/types';
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';
@@ -35,7 +36,7 @@
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
let offset = $state(0); let offset = $state(0);
let sseConnection: SSEConnection | null = null; let unsubscribeEventLog: (() => void) | null = null;
let listEl: HTMLDivElement | undefined = $state(); let listEl: HTMLDivElement | undefined = $state();
let showClearConfirm = $state(false); let showClearConfirm = $state(false);
@@ -198,16 +199,15 @@
loadEvents(); loadEvents();
loadStats(); loadStats();
sseConnection = connectGlobalEvents({ // Subscribe to event_log events from the global SSE connection (no duplicate connection).
onEventLog(payload) { unsubscribeEventLog = subscribeEventLog((payload: EventLogSSEPayload) => {
handleSSEEvent(payload); handleSSEEvent(payload);
}
}); });
}); });
onDestroy(() => { onDestroy(() => {
sseConnection?.close(); unsubscribeEventLog?.();
sseConnection = null; unsubscribeEventLog = null;
}); });
</script> </script>
+16 -15
View File
@@ -161,7 +161,7 @@
let showDeleteConfirm = $state(false); let showDeleteConfirm = $state(false);
const projectId = $derived($page.params.id); const projectId = $derived($page.params.id!); // always present on [id] route
async function loadProject() { async function loadProject() {
if (!project) loading = true; if (!project) loading = true;
@@ -188,21 +188,22 @@
} }
instancesByStage = mapped; instancesByStage = mapped;
try { // Fetch deploys, settings, and images in parallel (independent of each other).
const allDeploys = await api.listDeploys(20); const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([
deploys = allDeploys.filter((d) => d.project_id === projectId); api.listDeploys(20),
} catch { api.getSettings(),
deploys = []; api.listProjectImages(projectId)
} ]);
try { deploys = deploysResult.status === 'fulfilled'
const settings = await api.getSettings(); ? deploysResult.value.filter((d) => d.project_id === projectId)
settingsDomain = settings.domain ?? ''; : [];
} catch { /* non-critical */ } settingsDomain = settingsResult.status === 'fulfilled'
? (settingsResult.value.domain ?? '')
try { : settingsDomain;
localImages = await api.listProjectImages(projectId); localImages = imagesResult.status === 'fulfilled'
} catch { localImages = []; } ? imagesResult.value
: [];
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed'); error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
} finally { } finally {
+1 -1
View File
@@ -145,7 +145,7 @@
<h3 class="text-base font-semibold text-[var(--text-primary)]">{$t('settingsAuth.oidcConfig')}</h3> <h3 class="text-base font-semibold text-[var(--text-primary)]">{$t('settingsAuth.oidcConfig')}</h3>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
{#each [ {#each [
{ id: 'issuer', label: $t('settingsAuth.issuerUrl'), type: 'url', key: 'oidc_issuer_url', placeholder: 'https://auth.example.com/application/o/docker-watcher/' }, { id: 'issuer', label: $t('settingsAuth.issuerUrl'), type: 'url', key: 'oidc_issuer_url', placeholder: 'https://auth.example.com/application/o/tinyforge/' },
{ id: 'client_id', label: $t('settingsAuth.clientId'), type: 'text', key: 'oidc_client_id', placeholder: '' }, { id: 'client_id', label: $t('settingsAuth.clientId'), type: 'text', key: 'oidc_client_id', placeholder: '' },
{ id: 'client_secret', label: $t('settingsAuth.clientSecret'), type: 'password', key: 'oidc_client_secret', placeholder: '' }, { id: 'client_secret', label: $t('settingsAuth.clientSecret'), type: 'password', key: 'oidc_client_secret', placeholder: '' },
{ id: 'redirect', label: $t('settingsAuth.redirectUrl'), type: 'url', key: 'oidc_redirect_url', placeholder: 'https://watcher.example.com/api/auth/oidc/callback' } { id: 'redirect', label: $t('settingsAuth.redirectUrl'), type: 'url', key: 'oidc_redirect_url', placeholder: 'https://watcher.example.com/api/auth/oidc/callback' }