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:
@@ -12,7 +12,9 @@
|
||||
"Bash(git stash:*)",
|
||||
"Bash(echo \"EXIT: $?\")",
|
||||
"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": [
|
||||
"C:\\Users\\Alexei\\Documents\\docker-watcher\\internal",
|
||||
|
||||
@@ -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 .
|
||||
@@ -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
@@ -4,6 +4,6 @@ web/build/
|
||||
web/.svelte-kit/
|
||||
data/
|
||||
.env
|
||||
docker-watcher
|
||||
docker-watcher.exe
|
||||
tinyforge
|
||||
tinyforge.exe
|
||||
server.exe
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ COPY . .
|
||||
# Copy built frontend into the expected embed location.
|
||||
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
|
||||
FROM alpine:3.19
|
||||
@@ -34,7 +34,7 @@ RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=backend-builder /docker-watcher /app/docker-watcher
|
||||
COPY --from=backend-builder /tinyforge /app/tinyforge
|
||||
|
||||
# Data directory for SQLite database.
|
||||
RUN mkdir -p /app/data && chown -R app:app /app
|
||||
@@ -46,4 +46,4 @@ EXPOSE 8080
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV LISTEN_ADDR=:8080
|
||||
|
||||
ENTRYPOINT ["/app/docker-watcher"]
|
||||
ENTRYPOINT ["/app/tinyforge"]
|
||||
|
||||
@@ -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.
|
||||
@@ -9,7 +9,7 @@ build-frontend:
|
||||
|
||||
# Build the Go binary (embeds web/build/ via go:embed).
|
||||
build-backend:
|
||||
go build -o docker-watcher ./cmd/server
|
||||
go build -o tinyforge ./cmd/server
|
||||
|
||||
# Run in development mode with hot reload.
|
||||
# Requires air (go install github.com/air-verse/air@latest).
|
||||
@@ -18,4 +18,4 @@ dev:
|
||||
|
||||
# Clean build artifacts.
|
||||
clean:
|
||||
rm -rf web/build web/node_modules/.vite docker-watcher
|
||||
rm -rf web/build web/node_modules/.vite tinyforge
|
||||
|
||||
@@ -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
|
||||
|
||||
### Container Deployments
|
||||
|
||||
- **Registry polling** and **webhook receiver** for automatic deployments
|
||||
- **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
|
||||
- **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)
|
||||
- **Single binary** with embedded SPA frontend
|
||||
- **Role-based access**: Admin and user roles
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -36,7 +63,7 @@ Automated Docker deployment orchestrator with a web dashboard. Watches container
|
||||
# Generate a key: openssl rand -hex 32
|
||||
```
|
||||
|
||||
3. **Start Docker Watcher**:
|
||||
3. **Start Tinyforge**:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
@@ -48,19 +75,19 @@ Automated Docker deployment orchestrator with a web dashboard. Watches container
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `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 |
|
||||
| `SEED_FILE` | No | Path to YAML seed config (default: `./docker-watcher.yaml`) |
|
||||
| `DATA_DIR` | No | SQLite database directory (default: `./data`) |
|
||||
| `LISTEN_ADDR` | No | HTTP listen address (default: `:8080`) |
|
||||
| `NPM_URL` | No | Override NPM API URL (otherwise uses value from settings) |
|
||||
| `POLLING_INTERVAL` | No | Registry polling interval, Go duration string e.g. `5m` (default from settings) |
|
||||
| Variable | Required | Description |
|
||||
| ------------------ | ------------------- | -------------------------------------------------------------------------------- |
|
||||
| `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 |
|
||||
| `SEED_FILE` | No | Path to YAML seed config (default: `./tinyforge.yaml`) |
|
||||
| `DATA_DIR` | No | SQLite database directory (default: `./data`) |
|
||||
| `LISTEN_ADDR` | No | HTTP listen address (default: `:8080`) |
|
||||
| `NPM_URL` | No | Override NPM API URL (otherwise uses value from settings) |
|
||||
| `POLLING_INTERVAL` | No | Registry polling interval, Go duration string e.g. `5m` (default from settings) |
|
||||
|
||||
### 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
|
||||
|
||||
@@ -95,9 +122,11 @@ make dev
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
```text
|
||||
CI/Registry --> Webhook/Poller --> Deployer --> Docker + NPM
|
||||
|
|
||||
Git Repo ----> Static Sites -------> Docker + NPM
|
||||
|
|
||||
Event Bus --> SSE --> Web Dashboard
|
||||
```
|
||||
|
||||
|
||||
+25
-25
@@ -15,26 +15,26 @@ import (
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
dockerwatcher "github.com/alexei/docker-watcher"
|
||||
"github.com/alexei/docker-watcher/internal/api"
|
||||
"github.com/alexei/docker-watcher/internal/auth"
|
||||
"github.com/alexei/docker-watcher/internal/config"
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/backup"
|
||||
"github.com/alexei/docker-watcher/internal/deployer"
|
||||
"github.com/alexei/docker-watcher/internal/dns"
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/health"
|
||||
"github.com/alexei/docker-watcher/internal/logging"
|
||||
"github.com/alexei/docker-watcher/internal/notify"
|
||||
"github.com/alexei/docker-watcher/internal/npm"
|
||||
"github.com/alexei/docker-watcher/internal/proxy"
|
||||
"github.com/alexei/docker-watcher/internal/registry"
|
||||
"github.com/alexei/docker-watcher/internal/stale"
|
||||
"github.com/alexei/docker-watcher/internal/staticsite"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/docker-watcher/internal/webhook"
|
||||
tinyforge "github.com/alexei/tinyforge"
|
||||
"github.com/alexei/tinyforge/internal/api"
|
||||
"github.com/alexei/tinyforge/internal/auth"
|
||||
"github.com/alexei/tinyforge/internal/config"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/backup"
|
||||
"github.com/alexei/tinyforge/internal/deployer"
|
||||
"github.com/alexei/tinyforge/internal/dns"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/health"
|
||||
"github.com/alexei/tinyforge/internal/logging"
|
||||
"github.com/alexei/tinyforge/internal/notify"
|
||||
"github.com/alexei/tinyforge/internal/npm"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/registry"
|
||||
"github.com/alexei/tinyforge/internal/stale"
|
||||
"github.com/alexei/tinyforge/internal/staticsite"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/webhook"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -49,7 +49,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Open database.
|
||||
dbPath := filepath.Join(dataDir, "docker-watcher.db")
|
||||
dbPath := filepath.Join(dataDir, "tinyforge.db")
|
||||
db, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
slog.Error("open store", "error", err)
|
||||
@@ -65,7 +65,7 @@ func main() {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
slog.Error("seed import", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -306,7 +306,7 @@ func main() {
|
||||
|
||||
// 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.
|
||||
webBuildFS, err := fs.Sub(dockerwatcher.WebBuildFS, "web/build")
|
||||
webBuildFS, err := fs.Sub(tinyforge.WebBuildFS, "web/build")
|
||||
if err != nil {
|
||||
slog.Warn("embedded frontend not available", "error", err)
|
||||
} else {
|
||||
@@ -337,7 +337,7 @@ func main() {
|
||||
})
|
||||
|
||||
go func() {
|
||||
slog.Info("Docker Watcher started", "addr", addr)
|
||||
slog.Info("Tinyforge started", "addr", addr)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("HTTP server error", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -371,7 +371,7 @@ func main() {
|
||||
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.
|
||||
|
||||
+7
-7
@@ -1,8 +1,8 @@
|
||||
services:
|
||||
docker-watcher:
|
||||
tinyforge:
|
||||
build: .
|
||||
image: docker-watcher:latest
|
||||
container_name: docker-watcher
|
||||
image: tinyforge:latest
|
||||
container_name: tinyforge
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
@@ -10,16 +10,16 @@ services:
|
||||
# Mount Docker socket for container management.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Persistent data (SQLite database).
|
||||
- docker-watcher-data:/app/data
|
||||
- tinyforge-data:/app/data
|
||||
# Optional seed config (read on first launch only).
|
||||
- ./docker-watcher.yaml:/app/docker-watcher.yaml:ro
|
||||
- ./tinyforge.yaml:/app/tinyforge.yaml:ro
|
||||
environment:
|
||||
# Required: protects all credentials stored in the database.
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env}
|
||||
# Required on first launch: password for the default admin user.
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
|
||||
# Optional: override seed file location.
|
||||
- SEED_FILE=/app/docker-watcher.yaml
|
||||
- SEED_FILE=/app/tinyforge.yaml
|
||||
# Optional: override data directory.
|
||||
- DATA_DIR=/app/data
|
||||
# Optional: override listen address.
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
docker-watcher-data:
|
||||
tinyforge-data:
|
||||
driver: local
|
||||
|
||||
# NOTE: The staging-net network must exist before starting.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/alexei/docker-watcher
|
||||
module github.com/alexei/tinyforge
|
||||
|
||||
go 1.24.0
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/auth"
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/auth"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// rateLimitedLogin wraps the login handler with per-IP rate limiting.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"log/slog"
|
||||
"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.
|
||||
@@ -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-Disposition", "attachment; filename=docker-watcher.yaml")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=tinyforge.yaml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// listDeploys handles GET /api/deploys.
|
||||
|
||||
+3
-3
@@ -6,9 +6,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/dns"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/dns"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"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.
|
||||
@@ -220,7 +220,7 @@ func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if s.docker == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"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.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"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.
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// projectRequest is the expected JSON body for creating/updating a project.
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/registry"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/registry"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// registryRequest is the expected JSON body for creating/updating a registry.
|
||||
|
||||
+12
-12
@@ -7,18 +7,18 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/auth"
|
||||
"github.com/alexei/docker-watcher/internal/backup"
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/dns"
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/npm"
|
||||
"github.com/alexei/docker-watcher/internal/proxy"
|
||||
"github.com/alexei/docker-watcher/internal/stale"
|
||||
"github.com/alexei/docker-watcher/internal/staticsite"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/docker-watcher/internal/webhook"
|
||||
"github.com/alexei/tinyforge/internal/auth"
|
||||
"github.com/alexei/tinyforge/internal/backup"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/dns"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/npm"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/stale"
|
||||
"github.com/alexei/tinyforge/internal/staticsite"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/webhook"
|
||||
)
|
||||
|
||||
// DNSProviderChangedFunc is called when DNS settings change so the caller can
|
||||
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/dns"
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/npm"
|
||||
"github.com/alexei/docker-watcher/internal/proxy"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/docker-watcher/internal/volume"
|
||||
"github.com/alexei/docker-watcher/internal/webhook"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/dns"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/npm"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/volume"
|
||||
"github.com/alexei/tinyforge/internal/webhook"
|
||||
)
|
||||
|
||||
// settingsRequest is the expected JSON body for updating settings.
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// streamDeployLogs handles GET /api/deploys/{id}/logs.
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// stageEnvRequest is the expected JSON body for creating/updating a stage env override.
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// stageRequest is the expected JSON body for creating/updating a stage.
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/stale"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/stale"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// listStaleContainers handles GET /api/containers/stale.
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// ── List / Get ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"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.
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/docker-watcher/internal/volume"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/volume"
|
||||
)
|
||||
|
||||
// sanitizeFilename removes characters unsafe for Content-Disposition headers.
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/docker-watcher/internal/volume"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/volume"
|
||||
)
|
||||
|
||||
// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot.
|
||||
|
||||
@@ -41,7 +41,7 @@ type LocalAuth struct {
|
||||
// using HMAC-SHA256.
|
||||
func NewLocalAuth(encKey [32]byte) *LocalAuth {
|
||||
mac := hmac.New(sha256.New, encKey[:])
|
||||
mac.Write([]byte("docker-watcher-jwt-secret"))
|
||||
mac.Write([]byte("tinyforge-jwt-secret"))
|
||||
la := &LocalAuth{
|
||||
jwtSecret: mac.Sum(nil),
|
||||
blacklist: make(map[string]time.Time),
|
||||
@@ -110,7 +110,7 @@ func (la *LocalAuth) GenerateToken(claims Claims) (SessionToken, error) {
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "docker-watcher",
|
||||
Issuer: "tinyforge",
|
||||
},
|
||||
UserID: claims.UserID,
|
||||
Username: claims.Username,
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// Engine manages database backup operations.
|
||||
@@ -52,7 +52,7 @@ func (e *Engine) CreateBackup(backupType string) (store.Backup, error) {
|
||||
defer e.mu.Unlock()
|
||||
|
||||
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)
|
||||
|
||||
// VACUUM INTO creates a clean, standalone copy of the database.
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/dns"
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/health"
|
||||
"github.com/alexei/docker-watcher/internal/notify"
|
||||
"github.com/alexei/docker-watcher/internal/proxy"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/docker-watcher/internal/volume"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/dns"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/health"
|
||||
"github.com/alexei/tinyforge/internal/notify"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/volume"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package deployer
|
||||
import (
|
||||
"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
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// Labels applied to all containers managed by docker-watcher.
|
||||
// Labels applied to all containers managed by Tinyforge.
|
||||
const (
|
||||
LabelProject = "docker-watcher.project"
|
||||
LabelStage = "docker-watcher.stage"
|
||||
LabelInstanceID = "docker-watcher.instance-id"
|
||||
LabelProject = "tinyforge.project"
|
||||
LabelStage = "tinyforge.stage"
|
||||
LabelInstanceID = "tinyforge.instance-id"
|
||||
)
|
||||
|
||||
// Client wraps the Docker Engine API client.
|
||||
|
||||
@@ -36,16 +36,16 @@ type ContainerConfig struct {
|
||||
NetworkID string
|
||||
|
||||
// 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
|
||||
|
||||
// Project is the docker-watcher project name (used for labelling).
|
||||
// Project is the Tinyforge project name (used for labelling).
|
||||
Project string
|
||||
|
||||
// Stage is the docker-watcher stage name (used for labelling).
|
||||
// Stage is the Tinyforge stage name (used for labelling).
|
||||
Stage string
|
||||
|
||||
// InstanceID is the docker-watcher instance ID (used for labelling).
|
||||
// InstanceID is the Tinyforge instance ID (used for labelling).
|
||||
InstanceID string
|
||||
|
||||
// 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)
|
||||
for k, v := range cfg.Labels {
|
||||
labels[k] = v
|
||||
@@ -198,7 +198,7 @@ func (c *Client) RestartContainer(ctx context.Context, containerID string, timeo
|
||||
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 {
|
||||
ID string
|
||||
Name string
|
||||
@@ -212,12 +212,12 @@ type ManagedContainer struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Client) ListContainers(ctx context.Context, labelFilters map[string]string) ([]ManagedContainer, error) {
|
||||
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)
|
||||
|
||||
for k, v := range labelFilters {
|
||||
|
||||
@@ -32,7 +32,7 @@ func (c *Client) EnsureNetwork(ctx context.Context, networkName string) (string,
|
||||
resp, err := c.api.NetworkCreate(ctx, networkName, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Labels: map[string]string{
|
||||
LabelProject: "docker-watcher",
|
||||
LabelProject: "tinyforge",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/npm"
|
||||
"github.com/alexei/tinyforge/internal/npm"
|
||||
)
|
||||
|
||||
// NpmProvider wraps the NPM client behind the Provider interface.
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ func parseAPIFunctionName(funcName, baseRoute, importPath string) (RouteEntry, b
|
||||
}
|
||||
|
||||
// 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";
|
||||
|
||||
{{- range .Imports}}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/proxy"
|
||||
"github.com/alexei/docker-watcher/internal/staticsite/deno"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/staticsite/deno"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// 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,
|
||||
NetworkID: networkID,
|
||||
Labels: map[string]string{
|
||||
"docker-watcher.static-site": site.ID,
|
||||
"docker-watcher.static-site-name": site.Name,
|
||||
"tinyforge.static-site": site.ID,
|
||||
"tinyforge.static-site-name": site.Name,
|
||||
},
|
||||
Project: "static-site",
|
||||
Stage: site.Name,
|
||||
@@ -230,8 +230,8 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
|
||||
NetworkName: networkName,
|
||||
NetworkID: networkID,
|
||||
Labels: map[string]string{
|
||||
"docker-watcher.static-site": site.ID,
|
||||
"docker-watcher.static-site-name": site.Name,
|
||||
"tinyforge.static-site": site.ID,
|
||||
"tinyforge.static-site-name": site.Name,
|
||||
},
|
||||
Project: "static-site",
|
||||
Stage: site.Name,
|
||||
|
||||
@@ -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_api_url TEXT NOT NULL DEFAULT ''`,
|
||||
// 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.
|
||||
`ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`,
|
||||
// Resource limits per stage.
|
||||
@@ -219,7 +219,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
domain TEXT NOT NULL DEFAULT '',
|
||||
server_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}',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
npm_url TEXT NOT NULL DEFAULT '',
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"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.
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// AutoCreateProject creates a new project and a default "dev" stage from an
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// DeployTriggerer is called when a webhook determines a deploy should happen.
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// FindProjectAndStage searches for a project whose image matches the parsed
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
|
||||
set -euo pipefail
|
||||
@@ -32,6 +32,6 @@ export ENCRYPTION_KEY
|
||||
export ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}"
|
||||
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}"
|
||||
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.
|
||||
# After import, all configuration is managed via the Web UI.
|
||||
# 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)
|
||||
# and start Docker Watcher. Once imported, the file is never read again.
|
||||
# Place this file as ./tinyforge.yaml (or set SEED_FILE env var)
|
||||
# and start Tinyforge. Once imported, the file is never read again.
|
||||
|
||||
global:
|
||||
# Your base domain — must have a Cloudflare wildcard DNS record (*.domain)
|
||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "docker-watcher-web",
|
||||
"name": "tinyforge-web",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "docker-watcher-web",
|
||||
"name": "tinyforge-web",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "docker-watcher-web",
|
||||
"name": "tinyforge-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -25,6 +25,29 @@
|
||||
let logContainer: HTMLDivElement | undefined = $state();
|
||||
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() {
|
||||
loading = true;
|
||||
error = '';
|
||||
@@ -49,12 +72,7 @@
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.line) {
|
||||
lines = [...lines, data.line];
|
||||
// Trim to max lines.
|
||||
if (lines.length > tailCount * 2) {
|
||||
lines = lines.slice(-tailCount);
|
||||
}
|
||||
scrollToBottom();
|
||||
enqueueLine(data.line);
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
@@ -69,6 +87,9 @@
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
// Flush any buffered lines before stopping.
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
flushPendingLines();
|
||||
following = false;
|
||||
}
|
||||
|
||||
@@ -90,7 +111,10 @@
|
||||
// Load on mount.
|
||||
$effect(() => { loadLogs(); });
|
||||
|
||||
onDestroy(() => { stopFollowing(); });
|
||||
onDestroy(() => {
|
||||
stopFollowing();
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-page)] shadow-lg">
|
||||
|
||||
@@ -19,8 +19,11 @@
|
||||
|
||||
$effect(() => {
|
||||
let cancelled = false;
|
||||
let inflight = false;
|
||||
|
||||
async function load() {
|
||||
if (inflight) return; // Skip if previous request still pending.
|
||||
inflight = true;
|
||||
try {
|
||||
const result = await api.fetchContainerStats(projectId, stageId, instanceId);
|
||||
if (!cancelled) {
|
||||
@@ -31,13 +34,15 @@
|
||||
if (!cancelled) {
|
||||
error = true;
|
||||
}
|
||||
} finally {
|
||||
inflight = false;
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
// Poll every 10 seconds.
|
||||
const interval = setInterval(load, 10_000);
|
||||
// Poll every 30 seconds (reduced from 10s to limit concurrent connections).
|
||||
const interval = setInterval(load, 30_000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* Each icon is a standalone .svelte component accepting size and class props.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Docker Watcher",
|
||||
"name": "Tinyforge",
|
||||
"version": "v0.1"
|
||||
},
|
||||
"health": {
|
||||
@@ -508,7 +508,7 @@
|
||||
"password": "Password"
|
||||
},
|
||||
"login": {
|
||||
"title": "Docker Watcher",
|
||||
"title": "Tinyforge",
|
||||
"subtitle": "Sign in to your account",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
@@ -819,7 +819,7 @@
|
||||
},
|
||||
"dns": {
|
||||
"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",
|
||||
"wildcardActiveDesc": "DNS records are managed externally via wildcard DNS. Disable wildcard DNS in Settings to manage records individually.",
|
||||
"refresh": "Refresh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Docker Watcher",
|
||||
"name": "Tinyforge",
|
||||
"version": "v0.1"
|
||||
},
|
||||
"health": {
|
||||
@@ -508,7 +508,7 @@
|
||||
"password": "Пароль"
|
||||
},
|
||||
"login": {
|
||||
"title": "Docker Watcher",
|
||||
"title": "Tinyforge",
|
||||
"subtitle": "Войдите в свой аккаунт",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
@@ -819,7 +819,7 @@
|
||||
},
|
||||
"dns": {
|
||||
"title": "DNS-записи",
|
||||
"description": "Просмотр и управление DNS-записями, созданными Docker Watcher.",
|
||||
"description": "Просмотр и управление DNS-записями, созданными Tinyforge.",
|
||||
"wildcardActive": "Режим Wildcard DNS активен",
|
||||
"wildcardActiveDesc": "DNS-записи управляются внешне через wildcard DNS. Отключите wildcard DNS в настройках для индивидуального управления записями.",
|
||||
"refresh": "Обновить",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
import { fetchEventLog, fetchEventLogStats, clearAllEvents, deleteEvent } from '$lib/api';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
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 EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
|
||||
import EventLogFilter from '$lib/components/EventLogFilter.svelte';
|
||||
@@ -35,7 +36,7 @@
|
||||
const PAGE_SIZE = 50;
|
||||
let offset = $state(0);
|
||||
|
||||
let sseConnection: SSEConnection | null = null;
|
||||
let unsubscribeEventLog: (() => void) | null = null;
|
||||
let listEl: HTMLDivElement | undefined = $state();
|
||||
let showClearConfirm = $state(false);
|
||||
|
||||
@@ -198,16 +199,15 @@
|
||||
loadEvents();
|
||||
loadStats();
|
||||
|
||||
sseConnection = connectGlobalEvents({
|
||||
onEventLog(payload) {
|
||||
handleSSEEvent(payload);
|
||||
}
|
||||
// Subscribe to event_log events from the global SSE connection (no duplicate connection).
|
||||
unsubscribeEventLog = subscribeEventLog((payload: EventLogSSEPayload) => {
|
||||
handleSSEEvent(payload);
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
sseConnection?.close();
|
||||
sseConnection = null;
|
||||
unsubscribeEventLog?.();
|
||||
unsubscribeEventLog = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
const projectId = $derived($page.params.id);
|
||||
const projectId = $derived($page.params.id!); // always present on [id] route
|
||||
|
||||
async function loadProject() {
|
||||
if (!project) loading = true;
|
||||
@@ -188,21 +188,22 @@
|
||||
}
|
||||
instancesByStage = mapped;
|
||||
|
||||
try {
|
||||
const allDeploys = await api.listDeploys(20);
|
||||
deploys = allDeploys.filter((d) => d.project_id === projectId);
|
||||
} catch {
|
||||
deploys = [];
|
||||
}
|
||||
// Fetch deploys, settings, and images in parallel (independent of each other).
|
||||
const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([
|
||||
api.listDeploys(20),
|
||||
api.getSettings(),
|
||||
api.listProjectImages(projectId)
|
||||
]);
|
||||
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
settingsDomain = settings.domain ?? '';
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
try {
|
||||
localImages = await api.listProjectImages(projectId);
|
||||
} catch { localImages = []; }
|
||||
deploys = deploysResult.status === 'fulfilled'
|
||||
? deploysResult.value.filter((d) => d.project_id === projectId)
|
||||
: [];
|
||||
settingsDomain = settingsResult.status === 'fulfilled'
|
||||
? (settingsResult.value.domain ?? '')
|
||||
: settingsDomain;
|
||||
localImages = imagesResult.status === 'fulfilled'
|
||||
? imagesResult.value
|
||||
: [];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
|
||||
} finally {
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">{$t('settingsAuth.oidcConfig')}</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
{#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_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' }
|
||||
|
||||
Reference in New Issue
Block a user