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(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",
|
||||||
|
|||||||
@@ -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/
|
web/.svelte-kit/
|
||||||
data/
|
data/
|
||||||
.env
|
.env
|
||||||
docker-watcher
|
tinyforge
|
||||||
docker-watcher.exe
|
tinyforge.exe
|
||||||
server.exe
|
server.exe
|
||||||
|
|||||||
+3
-3
@@ -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"]
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,4 +1,4 @@
|
|||||||
module github.com/alexei/docker-watcher
|
module github.com/alexei/tinyforge
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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 '',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
package dockerwatcher
|
package tinyforge
|
||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
Reference in New Issue
Block a user