deleted controller
@@ -1,32 +0,0 @@
|
|||||||
# Build artifacts
|
|
||||||
bin/
|
|
||||||
*.exe
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Test artifacts
|
|
||||||
coverage.out
|
|
||||||
coverage.html
|
|
||||||
*.test
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Go
|
|
||||||
vendor/
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
*.tar
|
|
||||||
|
|
||||||
# Local config (don't commit real customer configs)
|
|
||||||
controller.yaml
|
|
||||||
restic-password
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
# Building & Publishing felhom-controller
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker installed on your build machine
|
|
||||||
- Docker Buildx plugin (for multi-arch builds — included with Docker Desktop, may need install on Linux)
|
|
||||||
- Access to Gitea at gitea.dooplex.hu
|
|
||||||
|
|
||||||
## Step 1: Enable Gitea Container Registry
|
|
||||||
|
|
||||||
Gitea has a built-in container registry. Check if it's enabled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# SSH into your k3s node or wherever Gitea runs
|
|
||||||
# Check Gitea config (app.ini)
|
|
||||||
kubectl exec -it -n <namespace> deploy/gitea -- cat /data/gitea/conf/app.ini | grep -A5 '\[packages\]'
|
|
||||||
```
|
|
||||||
|
|
||||||
If the `[packages]` section is missing or `ENABLED=false`, add/update:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[packages]
|
|
||||||
ENABLED = true
|
|
||||||
```
|
|
||||||
|
|
||||||
Then restart Gitea:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl rollout restart deploy/gitea -n <namespace>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify it works:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Login to the registry (use your Gitea username + password or access token)
|
|
||||||
docker login gitea.dooplex.hu
|
|
||||||
# Username: admin
|
|
||||||
# Password: <your-gitea-password-or-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
If login succeeds, the registry is working. The image URL pattern is:
|
|
||||||
`gitea.dooplex.hu/<owner>/<image>:<tag>`
|
|
||||||
|
|
||||||
## Step 2: Sync app assets before building
|
|
||||||
|
|
||||||
The container image includes app logos and screenshots. Sync them from
|
|
||||||
the felhom.eu website repo before building:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# If the website repo is checked out alongside this repo:
|
|
||||||
make sync-assets
|
|
||||||
|
|
||||||
# Or specify the path explicitly:
|
|
||||||
make sync-assets WEBSITE_ASSETS_DIR=/home/admin/repos/felhom.eu/website/assets
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
ls assets/*.webp
|
|
||||||
```
|
|
||||||
|
|
||||||
If you skip this step, the dashboard will work but show no app logos/screenshots
|
|
||||||
(the `onerror` handler hides broken images gracefully).
|
|
||||||
|
|
||||||
## Step 3: Build for single architecture (quick test)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/repos/deploy-felhom-compose
|
|
||||||
|
|
||||||
# Build for your current architecture
|
|
||||||
docker build \
|
|
||||||
--build-arg VERSION=$(git describe --tags --always 2>/dev/null || echo "0.1.0") \
|
|
||||||
--build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") \
|
|
||||||
-t gitea.dooplex.hu/admin/felhom-controller:latest \
|
|
||||||
-t gitea.dooplex.hu/admin/felhom-controller:0.1.0 \
|
|
||||||
.
|
|
||||||
|
|
||||||
# Push
|
|
||||||
docker push gitea.dooplex.hu/admin/felhom-controller:latest
|
|
||||||
docker push gitea.dooplex.hu/admin/felhom-controller:0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 4: Build for both architectures (production)
|
|
||||||
|
|
||||||
You need both amd64 (N100 mini PCs) and arm64 (Raspberry Pi). Use Docker Buildx:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# One-time: Create a buildx builder that supports multi-arch
|
|
||||||
docker buildx create --name felhom-builder --use --bootstrap
|
|
||||||
|
|
||||||
# Verify it supports the architectures we need
|
|
||||||
docker buildx inspect felhom-builder
|
|
||||||
# Should show: linux/amd64, linux/arm64 in Platforms
|
|
||||||
|
|
||||||
# Build + push multi-arch image in one step
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
--build-arg VERSION=0.1.0 \
|
|
||||||
--build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \
|
|
||||||
-t gitea.dooplex.hu/admin/felhom-controller:latest \
|
|
||||||
-t gitea.dooplex.hu/admin/felhom-controller:0.1.0 \
|
|
||||||
--push \
|
|
||||||
.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** `--push` is required with multi-arch builds because buildx doesn't store
|
|
||||||
multi-platform images in the local Docker cache. It pushes directly to the registry.
|
|
||||||
|
|
||||||
### If buildx multi-arch doesn't work (missing QEMU)
|
|
||||||
|
|
||||||
On Linux you might need QEMU for cross-compilation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install QEMU user-mode emulation
|
|
||||||
sudo apt-get install -y qemu-user-static binfmt-support
|
|
||||||
|
|
||||||
# Register QEMU with Docker
|
|
||||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
docker buildx ls
|
|
||||||
```
|
|
||||||
|
|
||||||
### Alternative: Build natively on each architecture
|
|
||||||
|
|
||||||
If you don't want to cross-compile, build on each machine:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# On the N100 (amd64):
|
|
||||||
docker build -t gitea.dooplex.hu/admin/felhom-controller:latest-amd64 .
|
|
||||||
docker push gitea.dooplex.hu/admin/felhom-controller:latest-amd64
|
|
||||||
|
|
||||||
# On the Pi (arm64):
|
|
||||||
docker build -t gitea.dooplex.hu/admin/felhom-controller:latest-arm64 .
|
|
||||||
docker push gitea.dooplex.hu/admin/felhom-controller:latest-arm64
|
|
||||||
|
|
||||||
# Then create a manifest list to combine them:
|
|
||||||
docker manifest create gitea.dooplex.hu/admin/felhom-controller:latest \
|
|
||||||
gitea.dooplex.hu/admin/felhom-controller:latest-amd64 \
|
|
||||||
gitea.dooplex.hu/admin/felhom-controller:latest-arm64
|
|
||||||
docker manifest push gitea.dooplex.hu/admin/felhom-controller:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 5: Deploy on a customer node
|
|
||||||
|
|
||||||
On the customer's machine, the docker-compose.yml for the controller references
|
|
||||||
the image from the registry:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
felhom-controller:
|
|
||||||
image: gitea.dooplex.hu/admin/felhom-controller:latest
|
|
||||||
# ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Pull and start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Login to registry on the customer node (one-time)
|
|
||||||
docker login gitea.dooplex.hu
|
|
||||||
|
|
||||||
# Start the controller
|
|
||||||
cd /opt/docker/felhom-controller
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
docker compose logs -f
|
|
||||||
curl -s http://localhost:8080/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 6: Updating the controller
|
|
||||||
|
|
||||||
When you release a new version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# On your build machine:
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
--build-arg VERSION=0.2.0 \
|
|
||||||
-t gitea.dooplex.hu/admin/felhom-controller:latest \
|
|
||||||
-t gitea.dooplex.hu/admin/felhom-controller:0.2.0 \
|
|
||||||
--push .
|
|
||||||
|
|
||||||
# On the customer node (manually for now; auto-update comes in Phase 5):
|
|
||||||
cd /opt/docker/felhom-controller
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Makefile shortcuts
|
|
||||||
|
|
||||||
The Makefile has convenience targets:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make docker-build # Build for current platform
|
|
||||||
make docker-buildx # Build multi-arch + push
|
|
||||||
make docker-push # Push current platform image
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "unauthorized" when pushing
|
|
||||||
```bash
|
|
||||||
docker logout gitea.dooplex.hu
|
|
||||||
docker login gitea.dooplex.hu
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gitea registry not accessible
|
|
||||||
Check if Gitea's HTTPS is working and the domain resolves:
|
|
||||||
```bash
|
|
||||||
curl -v https://gitea.dooplex.hu/v2/
|
|
||||||
# Should return: {"errors":[{"code":"UNAUTHORIZED",...}]}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build fails on arm64 via QEMU (too slow or errors)
|
|
||||||
Cross-compiling Go via QEMU can be slow. Since the Go binary itself is cross-compiled
|
|
||||||
(CGO_ENABLED=0), only the Debian packages in the runtime stage need QEMU.
|
|
||||||
Alternative: build the Go binary natively, then build only the runtime Docker layer
|
|
||||||
via buildx.
|
|
||||||
|
|
||||||
### Image too large
|
|
||||||
Expected sizes:
|
|
||||||
- Go binary: ~15-20 MB
|
|
||||||
- Runtime image (debian-slim + docker-cli + restic + pg_dump): ~250-350 MB
|
|
||||||
|
|
||||||
To reduce: consider Alpine instead of Debian slim, but test pg_dump/mysqldump
|
|
||||||
compatibility first.
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# felhom-controller Dockerfile
|
|
||||||
# Multi-stage build: Go binary + minimal runtime
|
|
||||||
# Supports amd64 (N100 mini PCs) and arm64 (Raspberry Pi)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# --- Build stage ---
|
|
||||||
FROM golang:1.22-bookworm AS builder
|
|
||||||
|
|
||||||
ARG TARGETOS=linux
|
|
||||||
ARG TARGETARCH
|
|
||||||
ARG VERSION=dev
|
|
||||||
ARG GIT_COMMIT=unknown
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
# Cache dependencies first
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy source
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build static binary
|
|
||||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
|
||||||
-ldflags="-s -w \
|
|
||||||
-X main.Version=${VERSION} \
|
|
||||||
-X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
|
||||||
-X main.GitCommit=${GIT_COMMIT}" \
|
|
||||||
-o /build/felhom-controller \
|
|
||||||
./cmd/controller/
|
|
||||||
|
|
||||||
# --- Runtime stage ---
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
# Install runtime dependencies:
|
|
||||||
# - docker-cli: for "docker compose" commands
|
|
||||||
# - ca-certificates: for HTTPS (healthchecks pings, git)
|
|
||||||
# - restic: for backup operations
|
|
||||||
# - postgresql-client: for pg_dump
|
|
||||||
# - default-mysql-client: for mysqldump
|
|
||||||
# - sqlite3: for SQLite backup
|
|
||||||
# - git: for stack sync from Gitea
|
|
||||||
# - curl: for health pings and debugging
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
restic \
|
|
||||||
postgresql-client \
|
|
||||||
default-mysql-client \
|
|
||||||
sqlite3 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install docker-cli (without daemon)
|
|
||||||
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg \
|
|
||||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] \
|
|
||||||
https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Create non-root user (but we'll run as root for Docker socket access)
|
|
||||||
# The Docker socket requires root or docker group membership
|
|
||||||
RUN mkdir -p /opt/docker/felhom-controller/data
|
|
||||||
|
|
||||||
COPY --from=builder /build/felhom-controller /usr/local/bin/felhom-controller
|
|
||||||
|
|
||||||
# Copy baked-in app assets (logos, screenshots)
|
|
||||||
# These are synced from the felhom.eu website repo before building.
|
|
||||||
# See: make sync-assets
|
|
||||||
COPY assets/ /usr/share/felhom/assets/
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:8080/api/health || exit 1
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/felhom-controller"]
|
|
||||||
CMD ["--config", "/opt/docker/felhom-controller/controller.yaml"]
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# felhom-controller Makefile
|
|
||||||
# Build targets for amd64 (N100 mini PCs) and arm64 (Raspberry Pi)
|
|
||||||
|
|
||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
|
||||||
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
||||||
BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
|
|
||||||
BINARY = felhom-controller
|
|
||||||
REGISTRY ?= gitea.dooplex.hu/admin
|
|
||||||
IMAGE = $(REGISTRY)/felhom-controller
|
|
||||||
|
|
||||||
LDFLAGS = -ldflags="-s -w \
|
|
||||||
-X main.Version=$(VERSION) \
|
|
||||||
-X main.BuildTime=$(BUILD_TIME) \
|
|
||||||
-X main.GitCommit=$(GIT_COMMIT)"
|
|
||||||
|
|
||||||
.PHONY: all build build-amd64 build-arm64 build-all test lint clean \
|
|
||||||
docker-build docker-push docker-buildx run sync-assets
|
|
||||||
|
|
||||||
# Default: build for current platform
|
|
||||||
all: build
|
|
||||||
|
|
||||||
build:
|
|
||||||
go build $(LDFLAGS) -o bin/$(BINARY) ./cmd/controller/
|
|
||||||
|
|
||||||
build-amd64:
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY)-linux-amd64 ./cmd/controller/
|
|
||||||
|
|
||||||
build-arm64:
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY)-linux-arm64 ./cmd/controller/
|
|
||||||
|
|
||||||
build-all: build-amd64 build-arm64
|
|
||||||
|
|
||||||
# Run locally (for development)
|
|
||||||
run:
|
|
||||||
go run ./cmd/controller/ --config configs/controller.yaml.example
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
test:
|
|
||||||
go test -v ./...
|
|
||||||
|
|
||||||
test-cover:
|
|
||||||
go test -coverprofile=coverage.out ./...
|
|
||||||
go tool cover -html=coverage.out -o coverage.html
|
|
||||||
|
|
||||||
# Lint (requires golangci-lint)
|
|
||||||
lint:
|
|
||||||
golangci-lint run ./...
|
|
||||||
|
|
||||||
# Docker image (current platform)
|
|
||||||
docker-build:
|
|
||||||
docker build \
|
|
||||||
--build-arg VERSION=$(VERSION) \
|
|
||||||
--build-arg GIT_COMMIT=$(GIT_COMMIT) \
|
|
||||||
-t $(IMAGE):$(VERSION) \
|
|
||||||
-t $(IMAGE):latest \
|
|
||||||
.
|
|
||||||
|
|
||||||
# Docker multi-arch build (requires docker buildx)
|
|
||||||
docker-buildx:
|
|
||||||
docker buildx build \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
--build-arg VERSION=$(VERSION) \
|
|
||||||
--build-arg GIT_COMMIT=$(GIT_COMMIT) \
|
|
||||||
-t $(IMAGE):$(VERSION) \
|
|
||||||
-t $(IMAGE):latest \
|
|
||||||
--push \
|
|
||||||
.
|
|
||||||
|
|
||||||
# Push docker image
|
|
||||||
docker-push:
|
|
||||||
docker push $(IMAGE):$(VERSION)
|
|
||||||
docker push $(IMAGE):latest
|
|
||||||
|
|
||||||
# Sync app assets from felhom.eu website repo
|
|
||||||
# Override with: make sync-assets WEBSITE_ASSETS_DIR=/path/to/website/assets
|
|
||||||
WEBSITE_ASSETS_DIR ?= ../felhom.eu/website/assets
|
|
||||||
|
|
||||||
sync-assets:
|
|
||||||
@echo "Syncing assets from $(WEBSITE_ASSETS_DIR)..."
|
|
||||||
@if [ ! -d "$(WEBSITE_ASSETS_DIR)" ]; then \
|
|
||||||
echo "ERROR: $(WEBSITE_ASSETS_DIR) not found."; \
|
|
||||||
echo "Set WEBSITE_ASSETS_DIR to the path containing app logos and screenshots."; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
@cp -v $(WEBSITE_ASSETS_DIR)/*-logo.svg assets/ 2>/dev/null || echo "No SVG logo files found"
|
|
||||||
@cp -v $(WEBSITE_ASSETS_DIR)/*-logo.png assets/ 2>/dev/null || echo "No PNG logo files found"
|
|
||||||
@cp -v $(WEBSITE_ASSETS_DIR)/*-screenshot-*.webp assets/ 2>/dev/null || echo "No screenshot files found"
|
|
||||||
@echo "Assets synced: $$(ls assets/*.svg assets/*.png assets/*.webp 2>/dev/null | wc -l) files"
|
|
||||||
|
|
||||||
# Generate bcrypt password hash (usage: make password PASS=mypassword)
|
|
||||||
password:
|
|
||||||
@go run -mod=mod golang.org/x/crypto/bcrypt 2>/dev/null || \
|
|
||||||
echo '$$2a$$10$$...' && echo "Install htpasswd or use: go run scripts/hashpass.go $(PASS)"
|
|
||||||
|
|
||||||
# Clean build artifacts
|
|
||||||
clean:
|
|
||||||
rm -rf bin/ coverage.out coverage.html
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# App Assets
|
|
||||||
|
|
||||||
This directory contains logos and screenshots for the dashboard.
|
|
||||||
They are baked into the Docker image at build time.
|
|
||||||
|
|
||||||
## Naming convention
|
|
||||||
|
|
||||||
Files must follow the felhom.eu website convention:
|
|
||||||
|
|
||||||
- `{slug}-logo.svg` — App logo (SVG preferred, displayed on dark background)
|
|
||||||
- `{slug}-logo.png` — App logo fallback (PNG, for apps without SVG)
|
|
||||||
- `{slug}-screenshot-1.webp` — First screenshot
|
|
||||||
- `{slug}-screenshot-2.webp` — Second screenshot (and so on)
|
|
||||||
|
|
||||||
The dashboard tries SVG first, falls back to PNG if not found.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```
|
|
||||||
paperless-ngx-logo.svg
|
|
||||||
paperless-ngx-screenshot-1.webp
|
|
||||||
adventurelog-logo.png
|
|
||||||
adventurelog-screenshot-1.webp
|
|
||||||
```
|
|
||||||
|
|
||||||
## Syncing from felhom.eu website
|
|
||||||
|
|
||||||
Run `make sync-assets` to copy assets from the felhom.eu website repo.
|
|
||||||
This expects the website files to be available at `../felhom.eu/website/assets/`
|
|
||||||
(relative to this repo), or set `WEBSITE_ASSETS_DIR` to override.
|
|
||||||
|
|
||||||
Alternatively, copy files manually from FileBrowser at https://felhom.eu.
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config is the top-level configuration structure.
|
|
||||||
// Contains ONLY infrastructure/customer identity.
|
|
||||||
// App-specific config lives in per-app app.yaml files.
|
|
||||||
type Config struct {
|
|
||||||
Customer CustomerConfig `yaml:"customer"`
|
|
||||||
Infrastructure InfrastructureConfig `yaml:"infrastructure"`
|
|
||||||
Paths PathsConfig `yaml:"paths"`
|
|
||||||
Web WebConfig `yaml:"web"`
|
|
||||||
Git GitConfig `yaml:"git"`
|
|
||||||
Stacks StacksConfig `yaml:"stacks"`
|
|
||||||
Backup BackupConfig `yaml:"backup"`
|
|
||||||
Monitoring MonitoringConfig `yaml:"monitoring"`
|
|
||||||
SelfUpdate SelfUpdateConfig `yaml:"self_update"`
|
|
||||||
Notifications NotificationsConfig `yaml:"notifications"`
|
|
||||||
Logging LoggingConfig `yaml:"logging"`
|
|
||||||
Assets AssetsConfig `yaml:"assets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CustomerConfig struct {
|
|
||||||
ID string `yaml:"id"`
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Domain string `yaml:"domain"`
|
|
||||||
Email string `yaml:"email"`
|
|
||||||
TelegramChatID string `yaml:"telegram_chat_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type InfrastructureConfig struct {
|
|
||||||
CFTunnelToken string `yaml:"cf_tunnel_token"`
|
|
||||||
CFAPIToken string `yaml:"cf_api_token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PathsConfig struct {
|
|
||||||
StacksDir string `yaml:"stacks_dir"`
|
|
||||||
DataDir string `yaml:"data_dir"`
|
|
||||||
BackupDir string `yaml:"backup_dir"`
|
|
||||||
DBDumpDir string `yaml:"db_dump_dir"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WebConfig struct {
|
|
||||||
Listen string `yaml:"listen"`
|
|
||||||
PasswordHash string `yaml:"password_hash"`
|
|
||||||
SessionSecret string `yaml:"session_secret"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GitConfig struct {
|
|
||||||
RepoURL string `yaml:"repo_url"`
|
|
||||||
Branch string `yaml:"branch"`
|
|
||||||
SyncInterval string `yaml:"sync_interval"`
|
|
||||||
Username string `yaml:"username"`
|
|
||||||
Token string `yaml:"token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type StacksConfig struct {
|
|
||||||
Protected []string `yaml:"protected"`
|
|
||||||
UpdateWindow string `yaml:"update_window"`
|
|
||||||
ComposeCommand string `yaml:"compose_command"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackupConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
ResticRepo string `yaml:"restic_repo"`
|
|
||||||
ResticPasswordFile string `yaml:"restic_password_file"`
|
|
||||||
DBDumpSchedule string `yaml:"db_dump_schedule"`
|
|
||||||
ResticSchedule string `yaml:"restic_schedule"`
|
|
||||||
Retention RetentionConfig `yaml:"retention"`
|
|
||||||
PruneSchedule string `yaml:"prune_schedule"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RetentionConfig struct {
|
|
||||||
KeepDaily int `yaml:"keep_daily"`
|
|
||||||
KeepWeekly int `yaml:"keep_weekly"`
|
|
||||||
KeepMonthly int `yaml:"keep_monthly"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MonitoringConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
HealthchecksBase string `yaml:"healthchecks_base"`
|
|
||||||
PingUUIDs PingUUIDsConfig `yaml:"ping_uuids"`
|
|
||||||
HealthCheckSchedule string `yaml:"health_check_schedule"`
|
|
||||||
Thresholds ThresholdsConfig `yaml:"thresholds"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PingUUIDsConfig struct {
|
|
||||||
DBDump string `yaml:"db_dump"`
|
|
||||||
Backup string `yaml:"backup"`
|
|
||||||
SystemHealth string `yaml:"system_health"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ThresholdsConfig struct {
|
|
||||||
DiskWarnPercent int `yaml:"disk_warn_percent"`
|
|
||||||
DiskCritPercent int `yaml:"disk_crit_percent"`
|
|
||||||
BackupMaxAgeHours int `yaml:"backup_max_age_hours"`
|
|
||||||
CPUWarnPercent int `yaml:"cpu_warn_percent"`
|
|
||||||
MemoryWarnPercent int `yaml:"memory_warn_percent"`
|
|
||||||
TemperatureWarnCelsius int `yaml:"temperature_warn_celsius"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelfUpdateConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
CheckInterval string `yaml:"check_interval"`
|
|
||||||
Image string `yaml:"image"`
|
|
||||||
AutoUpdate bool `yaml:"auto_update"`
|
|
||||||
HealthTimeoutSeconds int `yaml:"health_timeout_seconds"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NotificationsConfig struct {
|
|
||||||
CustomerEvents []string `yaml:"customer_events"`
|
|
||||||
OperatorEvents []string `yaml:"operator_events"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoggingConfig struct {
|
|
||||||
Level string `yaml:"level"`
|
|
||||||
File string `yaml:"file"`
|
|
||||||
MaxSizeMB int `yaml:"max_size_mb"`
|
|
||||||
MaxFiles int `yaml:"max_files"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AssetsConfig struct {
|
|
||||||
SourceURL string `yaml:"source_url"` // Only used during build, not runtime
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load reads and parses the config file, applies defaults, and validates.
|
|
||||||
func Load(path string) (*Config, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("reading config file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand environment variables in the YAML
|
|
||||||
expanded := os.ExpandEnv(string(data))
|
|
||||||
|
|
||||||
cfg := &Config{}
|
|
||||||
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing config file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDefaults(cfg)
|
|
||||||
applyEnvOverrides(cfg)
|
|
||||||
|
|
||||||
if err := validate(cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("config validation: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyDefaults(cfg *Config) {
|
|
||||||
d := func(val *string, def string) {
|
|
||||||
if *val == "" {
|
|
||||||
*val = def
|
|
||||||
}
|
|
||||||
}
|
|
||||||
di := func(val *int, def int) {
|
|
||||||
if *val == 0 {
|
|
||||||
*val = def
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
d(&cfg.Paths.StacksDir, "/opt/docker/stacks")
|
|
||||||
d(&cfg.Paths.DataDir, "/opt/docker/felhom-controller/data")
|
|
||||||
d(&cfg.Paths.BackupDir, "/srv/backups")
|
|
||||||
d(&cfg.Paths.DBDumpDir, "/srv/backups/db-dumps")
|
|
||||||
d(&cfg.Web.Listen, ":8080")
|
|
||||||
d(&cfg.Git.Branch, "main")
|
|
||||||
d(&cfg.Git.SyncInterval, "15m")
|
|
||||||
d(&cfg.Stacks.UpdateWindow, "03:00-05:00")
|
|
||||||
d(&cfg.Backup.ResticRepo, "/srv/backups/restic-repo")
|
|
||||||
d(&cfg.Backup.DBDumpSchedule, "02:30")
|
|
||||||
d(&cfg.Backup.ResticSchedule, "03:00")
|
|
||||||
d(&cfg.Backup.PruneSchedule, "weekly")
|
|
||||||
di(&cfg.Backup.Retention.KeepDaily, 7)
|
|
||||||
di(&cfg.Backup.Retention.KeepWeekly, 4)
|
|
||||||
di(&cfg.Backup.Retention.KeepMonthly, 6)
|
|
||||||
d(&cfg.Monitoring.HealthchecksBase, "https://status.felhom.eu")
|
|
||||||
d(&cfg.Monitoring.HealthCheckSchedule, "06:00")
|
|
||||||
di(&cfg.Monitoring.Thresholds.DiskWarnPercent, 80)
|
|
||||||
di(&cfg.Monitoring.Thresholds.DiskCritPercent, 90)
|
|
||||||
di(&cfg.Monitoring.Thresholds.BackupMaxAgeHours, 36)
|
|
||||||
di(&cfg.Monitoring.Thresholds.CPUWarnPercent, 90)
|
|
||||||
di(&cfg.Monitoring.Thresholds.MemoryWarnPercent, 85)
|
|
||||||
di(&cfg.Monitoring.Thresholds.TemperatureWarnCelsius, 75)
|
|
||||||
d(&cfg.SelfUpdate.CheckInterval, "6h")
|
|
||||||
di(&cfg.SelfUpdate.HealthTimeoutSeconds, 60)
|
|
||||||
d(&cfg.Logging.Level, "info")
|
|
||||||
di(&cfg.Logging.MaxSizeMB, 10)
|
|
||||||
di(&cfg.Logging.MaxFiles, 3)
|
|
||||||
d(&cfg.Assets.SourceURL, "https://felhom.eu")
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyEnvOverrides(cfg *Config) {
|
|
||||||
envStr := func(key string, target *string) {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
*target = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
envStr("FELHOM_CUSTOMER_ID", &cfg.Customer.ID)
|
|
||||||
envStr("FELHOM_CUSTOMER_DOMAIN", &cfg.Customer.Domain)
|
|
||||||
envStr("FELHOM_WEB_LISTEN", &cfg.Web.Listen)
|
|
||||||
envStr("FELHOM_WEB_PASSWORD_HASH", &cfg.Web.PasswordHash)
|
|
||||||
envStr("FELHOM_PATHS_STACKS_DIR", &cfg.Paths.StacksDir)
|
|
||||||
envStr("FELHOM_LOGGING_LEVEL", &cfg.Logging.Level)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validate(cfg *Config) error {
|
|
||||||
var errs []string
|
|
||||||
|
|
||||||
if cfg.Customer.ID == "" {
|
|
||||||
errs = append(errs, "customer.id is required")
|
|
||||||
}
|
|
||||||
if cfg.Customer.Domain == "" {
|
|
||||||
errs = append(errs, "customer.domain is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch cfg.Logging.Level {
|
|
||||||
case "debug", "info", "warn", "error":
|
|
||||||
default:
|
|
||||||
errs = append(errs, fmt.Sprintf("logging.level must be debug|info|warn|error, got %q", cfg.Logging.Level))
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Monitoring.Thresholds.DiskWarnPercent >= cfg.Monitoring.Thresholds.DiskCritPercent {
|
|
||||||
errs = append(errs, "disk_warn_percent must be less than disk_crit_percent")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return fmt.Errorf("validation errors:\n - %s", strings.Join(errs, "\n - "))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsProtectedStack checks if a stack name is in the protected list.
|
|
||||||
func (cfg *Config) IsProtectedStack(name string) bool {
|
|
||||||
for _, p := range cfg.Stacks.Protected {
|
|
||||||
if strings.EqualFold(p, name) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppLogoURL returns the primary logo URL (SVG). Use AppLogoPNGURL as fallback.
|
|
||||||
func (cfg *Config) AppLogoURL(slug string) string {
|
|
||||||
return fmt.Sprintf("/static/assets/%s-logo.svg", slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppLogoPNGURL returns the PNG fallback logo URL.
|
|
||||||
func (cfg *Config) AppLogoPNGURL(slug string) string {
|
|
||||||
return fmt.Sprintf("/static/assets/%s-logo.png", slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppScreenshotURL returns the local URL for an app's screenshot.
|
|
||||||
func (cfg *Config) AppScreenshotURL(slug string, index int) string {
|
|
||||||
return fmt.Sprintf("/static/assets/%s-screenshot-%d.webp", slug, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppPageURL returns the URL for an app's detail page.
|
|
||||||
// This links to the local controller-hosted app detail page.
|
|
||||||
func (cfg *Config) AppPageURL(slug string) string {
|
|
||||||
return fmt.Sprintf("/apps/%s", slug)
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# Felhom Controller Configuration
|
|
||||||
# =============================================================================
|
|
||||||
# Location: /opt/docker/felhom-controller/controller.yaml
|
|
||||||
#
|
|
||||||
# This file contains ONLY infrastructure and customer identity config.
|
|
||||||
# Application-specific configuration (passwords, paths, etc.) is handled
|
|
||||||
# interactively during first deployment via the dashboard UI and stored
|
|
||||||
# per-app in /opt/docker/stacks/<app>/app.yaml
|
|
||||||
#
|
|
||||||
# Environment variable overrides: FELHOM_<SECTION>_<KEY>
|
|
||||||
# (e.g., FELHOM_CUSTOMER_DOMAIN=example.hu)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# --- Customer identity ---
|
|
||||||
customer:
|
|
||||||
id: "demo-felhom" # Unique customer identifier
|
|
||||||
name: "Demo Ügyfél" # Display name (shown on dashboard)
|
|
||||||
domain: "demo-felhom.eu" # Base domain for all services
|
|
||||||
email: "" # Customer notification email (optional)
|
|
||||||
telegram_chat_id: "" # Telegram notifications (optional, future)
|
|
||||||
|
|
||||||
# --- Infrastructure secrets ---
|
|
||||||
infrastructure:
|
|
||||||
cf_tunnel_token: "" # Cloudflare Tunnel token
|
|
||||||
cf_api_token: "" # Cloudflare API token (DNS-01 challenge)
|
|
||||||
|
|
||||||
# --- Paths (system-level only) ---
|
|
||||||
paths:
|
|
||||||
stacks_dir: "/opt/docker/stacks" # Where compose files live
|
|
||||||
data_dir: "/opt/docker/felhom-controller/data"
|
|
||||||
backup_dir: "/srv/backups"
|
|
||||||
db_dump_dir: "/srv/backups/db-dumps"
|
|
||||||
|
|
||||||
# --- Web UI ---
|
|
||||||
web:
|
|
||||||
listen: ":8080"
|
|
||||||
# Bcrypt hash. Empty = first-visit setup prompt.
|
|
||||||
password_hash: ""
|
|
||||||
session_secret: "" # Auto-generated on first start
|
|
||||||
|
|
||||||
# --- Git synchronization ---
|
|
||||||
git:
|
|
||||||
repo_url: "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
|
|
||||||
branch: "main"
|
|
||||||
sync_interval: "15m"
|
|
||||||
username: ""
|
|
||||||
token: ""
|
|
||||||
|
|
||||||
# --- Stack management ---
|
|
||||||
stacks:
|
|
||||||
protected:
|
|
||||||
- "traefik"
|
|
||||||
- "cloudflared"
|
|
||||||
- "felhom-controller"
|
|
||||||
update_window: "03:00-05:00"
|
|
||||||
compose_command: ""
|
|
||||||
|
|
||||||
# --- Backup ---
|
|
||||||
backup:
|
|
||||||
enabled: true
|
|
||||||
restic_repo: "/srv/backups/restic-repo"
|
|
||||||
restic_password_file: "/opt/docker/felhom-controller/restic-password"
|
|
||||||
db_dump_schedule: "02:30"
|
|
||||||
restic_schedule: "03:00"
|
|
||||||
retention:
|
|
||||||
keep_daily: 7
|
|
||||||
keep_weekly: 4
|
|
||||||
keep_monthly: 6
|
|
||||||
prune_schedule: "weekly"
|
|
||||||
|
|
||||||
# --- Monitoring ---
|
|
||||||
monitoring:
|
|
||||||
enabled: true
|
|
||||||
healthchecks_base: "https://status.felhom.eu"
|
|
||||||
ping_uuids:
|
|
||||||
db_dump: "CHANGEME-uuid-for-db-dump"
|
|
||||||
backup: "CHANGEME-uuid-for-backup"
|
|
||||||
system_health: "CHANGEME-uuid-for-system-health"
|
|
||||||
health_check_schedule: "06:00"
|
|
||||||
thresholds:
|
|
||||||
disk_warn_percent: 80
|
|
||||||
disk_crit_percent: 90
|
|
||||||
backup_max_age_hours: 36
|
|
||||||
cpu_warn_percent: 90
|
|
||||||
memory_warn_percent: 85
|
|
||||||
temperature_warn_celsius: 75
|
|
||||||
|
|
||||||
# --- Self-update ---
|
|
||||||
self_update:
|
|
||||||
enabled: true
|
|
||||||
check_interval: "6h"
|
|
||||||
image: "gitea.dooplex.hu/admin/felhom-controller"
|
|
||||||
auto_update: false
|
|
||||||
health_timeout_seconds: 60
|
|
||||||
|
|
||||||
# --- Notifications ---
|
|
||||||
notifications:
|
|
||||||
customer_events:
|
|
||||||
- "disk_warning"
|
|
||||||
- "backup_failed"
|
|
||||||
- "update_available"
|
|
||||||
- "security_update"
|
|
||||||
operator_events:
|
|
||||||
- "disk_critical"
|
|
||||||
- "backup_failed"
|
|
||||||
- "self_update_failed"
|
|
||||||
- "container_unhealthy"
|
|
||||||
|
|
||||||
# --- Logging ---
|
|
||||||
logging:
|
|
||||||
level: "info"
|
|
||||||
file: ""
|
|
||||||
max_size_mb: 10
|
|
||||||
max_files: 3
|
|
||||||
|
|
||||||
# --- Assets ---
|
|
||||||
assets:
|
|
||||||
# App logos, screenshots, and descriptions are baked into the container
|
|
||||||
# image at build time (from the felhom.eu website assets).
|
|
||||||
# Served locally at /static/assets/ — no external dependency.
|
|
||||||
# The source URL is only used during image build, not at runtime.
|
|
||||||
source_url: "https://felhom.eu"
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
package stacks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AppConfig holds the per-app deployment configuration.
|
|
||||||
// Saved as app.yaml in each stack directory after first deployment.
|
|
||||||
type AppConfig struct {
|
|
||||||
Deployed bool `yaml:"deployed" json:"deployed"`
|
|
||||||
DeployedAt string `yaml:"deployed_at" json:"deployed_at"`
|
|
||||||
Env map[string]string `yaml:"env" json:"env"`
|
|
||||||
LockedFields []string `yaml:"locked_fields" json:"locked_fields"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeployRequest contains the user-provided values from the deploy form.
|
|
||||||
type DeployRequest struct {
|
|
||||||
StackName string `json:"stack_name"`
|
|
||||||
Values map[string]string `json:"values"` // env_var -> user-provided value
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeployStack handles first-time deployment of an app:
|
|
||||||
// 1. Load metadata (.felhom.yml) to know what fields exist
|
|
||||||
// 2. Auto-generate secrets for secret/password fields without user values
|
|
||||||
// 3. Auto-fill domain from controller config
|
|
||||||
// 4. Merge with user-provided values
|
|
||||||
// 5. Save app.yaml
|
|
||||||
// 6. Run docker compose up -d with env vars
|
|
||||||
func (m *Manager) DeployStack(req DeployRequest) error {
|
|
||||||
stack, ok := m.GetStack(req.StackName)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("stack %q not found", req.StackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
stackDir := filepath.Dir(stack.ComposePath)
|
|
||||||
meta := LoadMetadata(stackDir)
|
|
||||||
|
|
||||||
// Check if already deployed
|
|
||||||
existing := LoadAppConfig(stackDir)
|
|
||||||
if existing != nil && existing.Deployed {
|
|
||||||
return fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the full env map
|
|
||||||
env := make(map[string]string)
|
|
||||||
var lockedFields []string
|
|
||||||
|
|
||||||
for _, field := range meta.DeployFields {
|
|
||||||
var value string
|
|
||||||
|
|
||||||
switch field.Type {
|
|
||||||
case "domain":
|
|
||||||
// Auto-fill from controller config
|
|
||||||
value = m.cfg.Customer.Domain
|
|
||||||
|
|
||||||
case "secret":
|
|
||||||
// Always auto-generate, user never sees these
|
|
||||||
generated, err := generateValue(field.Generate)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("generating %s: %w", field.EnvVar, err)
|
|
||||||
}
|
|
||||||
value = generated
|
|
||||||
|
|
||||||
case "password":
|
|
||||||
// Use user value if provided, otherwise generate
|
|
||||||
if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
|
|
||||||
value = userVal
|
|
||||||
} else if field.Generate != "" {
|
|
||||||
generated, err := generateValue(field.Generate)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("generating %s: %w", field.EnvVar, err)
|
|
||||||
}
|
|
||||||
value = generated
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
// text, path, select, boolean — use user value or default
|
|
||||||
if userVal, ok := req.Values[field.EnvVar]; ok {
|
|
||||||
value = userVal
|
|
||||||
} else if field.Default != "" {
|
|
||||||
value = field.Default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if field.Required && value == "" {
|
|
||||||
return fmt.Errorf("required field %q (%s) is empty", field.Label, field.EnvVar)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate path fields exist
|
|
||||||
if field.Type == "path" && value != "" {
|
|
||||||
if _, err := os.Stat(value); os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("path %q does not exist for field %q", value, field.Label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if value != "" {
|
|
||||||
env[field.EnvVar] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
if field.LockedAfterDeploy {
|
|
||||||
lockedFields = append(lockedFields, field.EnvVar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save app.yaml
|
|
||||||
appCfg := &AppConfig{
|
|
||||||
Deployed: true,
|
|
||||||
DeployedAt: time.Now().UTC().Format(time.RFC3339),
|
|
||||||
Env: env,
|
|
||||||
LockedFields: lockedFields,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SaveAppConfig(stackDir, appCfg); err != nil {
|
|
||||||
return fmt.Errorf("saving app config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Deploying stack %s with %d env vars", req.StackName, len(env))
|
|
||||||
|
|
||||||
// Run docker compose up -d
|
|
||||||
_, err := m.composeExecWithEnv(stackDir, env, "up", "-d")
|
|
||||||
if err != nil {
|
|
||||||
// Deployment failed — keep app.yaml for debugging but mark as not deployed
|
|
||||||
appCfg.Deployed = false
|
|
||||||
_ = SaveAppConfig(stackDir, appCfg)
|
|
||||||
return fmt.Errorf("docker compose up failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Stack %s deployed successfully", req.StackName)
|
|
||||||
return m.RefreshStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStackConfig updates non-locked fields for a deployed stack.
|
|
||||||
func (m *Manager) UpdateStackConfig(name string, values map[string]string) error {
|
|
||||||
stack, ok := m.GetStack(name)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("stack %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
stackDir := filepath.Dir(stack.ComposePath)
|
|
||||||
appCfg := LoadAppConfig(stackDir)
|
|
||||||
if appCfg == nil || !appCfg.Deployed {
|
|
||||||
return fmt.Errorf("stack %q is not deployed yet", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply changes, respecting locked fields
|
|
||||||
lockedSet := make(map[string]bool)
|
|
||||||
for _, f := range appCfg.LockedFields {
|
|
||||||
lockedSet[f] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, val := range values {
|
|
||||||
if lockedSet[key] {
|
|
||||||
return fmt.Errorf("field %q is locked and cannot be changed after deployment", key)
|
|
||||||
}
|
|
||||||
appCfg.Env[key] = val
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SaveAppConfig(stackDir, appCfg); err != nil {
|
|
||||||
return fmt.Errorf("saving updated config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restart with new env
|
|
||||||
_, err := m.composeExecWithEnv(stackDir, appCfg.Env, "up", "-d")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("restarting with new config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Stack %s config updated and restarted", name)
|
|
||||||
return m.RefreshStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// composeExecWithEnv runs a compose command with custom env vars injected.
|
|
||||||
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
|
|
||||||
// Build env slice: start with os env, then add our vars
|
|
||||||
cmdEnv := os.Environ()
|
|
||||||
for k, v := range env {
|
|
||||||
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
|
|
||||||
}
|
|
||||||
// Always inject DOMAIN from controller config
|
|
||||||
cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
|
|
||||||
|
|
||||||
return m.composeExecCustomEnv(dir, cmdEnv, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDeployFields returns the deployment fields for a stack (for the deploy form).
|
|
||||||
func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
|
|
||||||
stack, ok := m.GetStack(name)
|
|
||||||
if !ok {
|
|
||||||
return nil, nil, fmt.Errorf("stack %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
stackDir := filepath.Dir(stack.ComposePath)
|
|
||||||
meta := LoadMetadata(stackDir)
|
|
||||||
appCfg := LoadAppConfig(stackDir)
|
|
||||||
|
|
||||||
return &meta, appCfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- App config persistence ---
|
|
||||||
|
|
||||||
// LoadAppConfig reads app.yaml from a stack directory.
|
|
||||||
// Returns nil if the file doesn't exist.
|
|
||||||
func LoadAppConfig(stackDir string) *AppConfig {
|
|
||||||
path := filepath.Join(stackDir, "app.yaml")
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &AppConfig{}
|
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveAppConfig writes app.yaml to a stack directory.
|
|
||||||
func SaveAppConfig(stackDir string, cfg *AppConfig) error {
|
|
||||||
data, err := yaml.Marshal(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshaling app config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(stackDir, "app.yaml")
|
|
||||||
|
|
||||||
header := "# Auto-generated by felhom-controller — do not edit locked fields manually\n"
|
|
||||||
content := header + string(data)
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
|
||||||
return fmt.Errorf("writing %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Secret generation ---
|
|
||||||
|
|
||||||
const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
||||||
|
|
||||||
// generateValue creates a random value based on the generator spec.
|
|
||||||
// Formats: "password:N", "hex:N", "static:VALUE"
|
|
||||||
func generateValue(spec string) (string, error) {
|
|
||||||
if spec == "" {
|
|
||||||
return "", fmt.Errorf("empty generator spec")
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.SplitN(spec, ":", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return "", fmt.Errorf("invalid generator spec: %q (expected type:param)", spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
genType := parts[0]
|
|
||||||
param := parts[1]
|
|
||||||
|
|
||||||
switch genType {
|
|
||||||
case "password":
|
|
||||||
length := 0
|
|
||||||
if _, err := fmt.Sscanf(param, "%d", &length); err != nil || length <= 0 {
|
|
||||||
return "", fmt.Errorf("invalid password length: %q", param)
|
|
||||||
}
|
|
||||||
return randomAlphanumeric(length)
|
|
||||||
|
|
||||||
case "hex":
|
|
||||||
byteLen := 0
|
|
||||||
if _, err := fmt.Sscanf(param, "%d", &byteLen); err != nil || byteLen <= 0 {
|
|
||||||
return "", fmt.Errorf("invalid hex length: %q", param)
|
|
||||||
}
|
|
||||||
b := make([]byte, byteLen)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
return "", fmt.Errorf("reading random bytes: %w", err)
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(b), nil
|
|
||||||
|
|
||||||
case "static":
|
|
||||||
return param, nil
|
|
||||||
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown generator type: %q", genType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomAlphanumeric(length int) (string, error) {
|
|
||||||
result := make([]byte, length)
|
|
||||||
for i := range result {
|
|
||||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphanumChars))))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
result[i] = alphanumChars[n.Int64()]
|
|
||||||
}
|
|
||||||
return string(result), nil
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# felhom-controller Docker Compose
|
|
||||||
# This is deployed as an infrastructure component alongside Traefik/Cloudflared
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
services:
|
|
||||||
felhom-controller:
|
|
||||||
image: gitea.dooplex.hu/admin/felhom-controller:latest
|
|
||||||
container_name: felhom-controller
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
# Docker socket — required for compose operations
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
# Controller config
|
|
||||||
- /opt/docker/felhom-controller/controller.yaml:/opt/docker/felhom-controller/controller.yaml:ro
|
|
||||||
# Controller persistent data (sessions, state)
|
|
||||||
- controller-data:/opt/docker/felhom-controller/data
|
|
||||||
# Stack compose files (read + write for git sync)
|
|
||||||
- /opt/docker/stacks:/opt/docker/stacks
|
|
||||||
# Backup directories
|
|
||||||
- /srv/backups:/srv/backups
|
|
||||||
# Restic password file
|
|
||||||
- /opt/docker/felhom-controller/restic-password:/opt/docker/felhom-controller/restic-password:ro
|
|
||||||
# HDD mount (if available, for backup paths)
|
|
||||||
- ${HDD_PATH:-/mnt/hdd_placeholder}:${HDD_PATH:-/mnt/hdd_placeholder}:ro
|
|
||||||
environment:
|
|
||||||
- TZ=Europe/Budapest
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.controller.rule=Host(`dashboard.${DOMAIN}`)"
|
|
||||||
- "traefik.http.routers.controller.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.controller.tls=true"
|
|
||||||
- "traefik.http.services.controller.loadbalancer.server.port=8080"
|
|
||||||
- "traefik.docker.network=traefik-public"
|
|
||||||
# Health check labels for monitoring
|
|
||||||
- "felhom.managed=true"
|
|
||||||
- "felhom.component=controller"
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
start_period: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
controller-data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik-public:
|
|
||||||
external: true
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# .felhom.yml — App metadata for felhom-controller
|
|
||||||
# =============================================================================
|
|
||||||
# Place alongside docker-compose.yml in each stack directory:
|
|
||||||
# /opt/docker/stacks/paperless-ngx/.felhom.yml
|
|
||||||
#
|
|
||||||
# This file defines:
|
|
||||||
# 1. Display info (name, description, icon)
|
|
||||||
# 2. Deploy fields (what the user fills in during first deployment)
|
|
||||||
# 3. Asset references (logos, screenshots loaded from felhom.eu)
|
|
||||||
# 4. Resource hints (RAM, Pi compatibility)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# --- Display info (shown on dashboard) ---
|
|
||||||
display_name: "Paperless-ngx"
|
|
||||||
description: "Dokumentumok digitalizálása és rendszerezése"
|
|
||||||
category: "productivity" # productivity, media, finance, security, tools
|
|
||||||
subdomain: "paperless" # -> paperless.<domain>
|
|
||||||
|
|
||||||
# --- Asset slug ---
|
|
||||||
# Used to construct URLs for logo and screenshots from felhom.eu:
|
|
||||||
# Logo: {assets.base_url}/assets/{slug}-logo.webp
|
|
||||||
# Screenshot: {assets.base_url}/assets/{slug}-screenshot-{n}.webp
|
|
||||||
# App page: {assets.base_url}/alkalmazasok#{slug}
|
|
||||||
# Falls back to directory name if not set.
|
|
||||||
slug: "paperless-ngx"
|
|
||||||
|
|
||||||
# --- Resource hints (displayed on deploy screen) ---
|
|
||||||
resources:
|
|
||||||
ram: "~500MB"
|
|
||||||
pi_compatible: true # Runs on Raspberry Pi 3B+
|
|
||||||
needs_hdd: true # Needs external storage for user data
|
|
||||||
|
|
||||||
# --- Deploy fields ---
|
|
||||||
# Shown to the user during first deployment.
|
|
||||||
# After deployment, values are saved to app.yaml in the stack directory.
|
|
||||||
#
|
|
||||||
# Field types:
|
|
||||||
# domain - Auto-filled from controller config, read-only
|
|
||||||
# secret - Auto-generated, hidden (user sees "Generated ✓")
|
|
||||||
# password - Auto-generated but shown, user can override
|
|
||||||
# path - Filesystem path (validated for existence)
|
|
||||||
# text - Free text input
|
|
||||||
# select - Dropdown with predefined options
|
|
||||||
# boolean - Toggle switch
|
|
||||||
#
|
|
||||||
# Generator types (for secret/password):
|
|
||||||
# password:N - N chars alphanumeric
|
|
||||||
# hex:N - N bytes hex-encoded
|
|
||||||
# static:VAL - Fixed value
|
|
||||||
|
|
||||||
deploy_fields:
|
|
||||||
- env_var: DOMAIN
|
|
||||||
label: "Domain"
|
|
||||||
type: domain
|
|
||||||
description: "A szerver domain neve"
|
|
||||||
locked_after_deploy: true
|
|
||||||
|
|
||||||
- env_var: DB_PASSWORD
|
|
||||||
label: "Adatbázis jelszó"
|
|
||||||
type: secret
|
|
||||||
generate: "password:24"
|
|
||||||
locked_after_deploy: true
|
|
||||||
|
|
||||||
- env_var: PAPERLESS_SECRET_KEY
|
|
||||||
label: "Titkosítási kulcs"
|
|
||||||
type: secret
|
|
||||||
generate: "hex:32"
|
|
||||||
locked_after_deploy: true
|
|
||||||
|
|
||||||
- env_var: PAPERLESS_ADMIN_USER
|
|
||||||
label: "Admin felhasználónév"
|
|
||||||
type: text
|
|
||||||
default: "admin"
|
|
||||||
locked_after_deploy: false
|
|
||||||
|
|
||||||
- env_var: PAPERLESS_ADMIN_PASSWORD
|
|
||||||
label: "Admin jelszó"
|
|
||||||
type: password
|
|
||||||
generate: "password:16"
|
|
||||||
description: "Első bejelentkezéshez. Utána a webes felületen módosítható."
|
|
||||||
locked_after_deploy: false
|
|
||||||
|
|
||||||
- env_var: HDD_PATH
|
|
||||||
label: "Adattárolási útvonal"
|
|
||||||
type: path
|
|
||||||
required: true
|
|
||||||
placeholder: "/mnt/hdd_1"
|
|
||||||
description: "A külső merevlemez elérési útja, ahol a dokumentumok tárolódnak"
|
|
||||||
locked_after_deploy: true
|
|
||||||
|
|
||||||
- env_var: PAPERLESS_OCR_LANGUAGE
|
|
||||||
label: "OCR nyelv"
|
|
||||||
type: select
|
|
||||||
default: "hun+eng"
|
|
||||||
options:
|
|
||||||
- value: "hun"
|
|
||||||
label: "Magyar"
|
|
||||||
- value: "eng"
|
|
||||||
label: "Angol"
|
|
||||||
- value: "hun+eng"
|
|
||||||
label: "Magyar + Angol"
|
|
||||||
- value: "deu+eng"
|
|
||||||
label: "Német + Angol"
|
|
||||||
description: "Dokumentum felismerés nyelve"
|
|
||||||
locked_after_deploy: false
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
module gitea.dooplex.hu/admin/felhom-controller
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
|
|
||||||
require (
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
golang.org/x/crypto v0.31.0
|
|
||||||
)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Usage: go run scripts/hashpass.go <password>
|
|
||||||
// Outputs a bcrypt hash suitable for controller.yaml password_hash field.
|
|
||||||
func main() {
|
|
||||||
if len(os.Args) < 2 {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: %s <password>\n", os.Args[0])
|
|
||||||
fmt.Fprintf(os.Stderr, "Generates a bcrypt hash for the felhom-controller config.\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
password := os.Args[1]
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error generating hash: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(string(hash))
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// Set at build time via ldflags
|
|
||||||
Version = "dev"
|
|
||||||
BuildTime = "unknown"
|
|
||||||
GitCommit = "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
configPath := flag.String("config", "/opt/docker/felhom-controller/controller.yaml", "Path to configuration file")
|
|
||||||
showVersion := flag.Bool("version", false, "Show version and exit")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *showVersion {
|
|
||||||
fmt.Printf("felhom-controller %s (built %s, commit %s)\n", Version, BuildTime, GitCommit)
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Load configuration ---
|
|
||||||
cfg, err := config.Load(*configPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("[FATAL] Failed to load config from %s: %v", *configPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger := setupLogger(cfg)
|
|
||||||
logger.Printf("[INFO] felhom-controller %s starting (customer: %s, domain: %s)",
|
|
||||||
Version, cfg.Customer.ID, cfg.Customer.Domain)
|
|
||||||
|
|
||||||
// --- Initialize stack manager ---
|
|
||||||
stackMgr, err := stacks.NewManager(cfg, logger)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("[FATAL] Failed to initialize stack manager: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial stack scan
|
|
||||||
if err := stackMgr.ScanStacks(); err != nil {
|
|
||||||
logger.Printf("[WARN] Initial stack scan failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Initialize API router ---
|
|
||||||
apiRouter := api.NewRouter(cfg, stackMgr, logger)
|
|
||||||
|
|
||||||
// --- Initialize web server ---
|
|
||||||
webServer := web.NewServer(cfg, stackMgr, logger, Version)
|
|
||||||
|
|
||||||
// --- Build HTTP mux ---
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
// API routes (no auth for health endpoint, auth for everything else)
|
|
||||||
mux.HandleFunc("/api/health", apiRouter.HealthHandler)
|
|
||||||
mux.Handle("/api/", webServer.RequireAuth(http.HandlerFunc(apiRouter.ServeHTTP)))
|
|
||||||
|
|
||||||
// Web UI routes (auth required)
|
|
||||||
mux.Handle("/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHTTP)))
|
|
||||||
|
|
||||||
// --- Start HTTP server ---
|
|
||||||
server := &http.Server{
|
|
||||||
Addr: cfg.Web.Listen,
|
|
||||||
Handler: mux,
|
|
||||||
ReadTimeout: 30 * time.Second,
|
|
||||||
WriteTimeout: 60 * time.Second,
|
|
||||||
IdleTimeout: 120 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Graceful shutdown ---
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
sig := <-sigCh
|
|
||||||
logger.Printf("[INFO] Received signal %v, shutting down...", sig)
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer shutdownCancel()
|
|
||||||
|
|
||||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
|
||||||
logger.Printf("[ERROR] HTTP server shutdown error: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// --- Start background tasks ---
|
|
||||||
// Periodic stack status refresh
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if err := stackMgr.RefreshStatus(); err != nil {
|
|
||||||
logger.Printf("[WARN] Status refresh failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
logger.Printf("[INFO] Web UI listening on %s", cfg.Web.Listen)
|
|
||||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
|
||||||
logger.Fatalf("[FATAL] HTTP server error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Println("[INFO] felhom-controller stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupLogger(cfg *config.Config) *log.Logger {
|
|
||||||
// For now, log to stdout. File logging will be added later.
|
|
||||||
logger := log.New(os.Stdout, "", log.LstdFlags)
|
|
||||||
|
|
||||||
if cfg.Logging.Level == "debug" {
|
|
||||||
logger.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
return logger
|
|
||||||
}
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
package stacks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ContainerState represents the current state of a container.
|
|
||||||
type ContainerState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StateRunning ContainerState = "running"
|
|
||||||
StateStopped ContainerState = "stopped"
|
|
||||||
StateRestarting ContainerState = "restarting"
|
|
||||||
StateExited ContainerState = "exited"
|
|
||||||
StatePaused ContainerState = "paused"
|
|
||||||
StateUnknown ContainerState = "unknown"
|
|
||||||
StateNotDeployed ContainerState = "not_deployed"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ContainerInfo holds status info about a single container within a stack.
|
|
||||||
type ContainerInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
State ContainerState `json:"state"`
|
|
||||||
Status string `json:"status"` // e.g. "Up 3 hours"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stack represents a docker compose stack on disk.
|
|
||||||
type Stack struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Meta Metadata `json:"meta"`
|
|
||||||
ComposePath string `json:"compose_path"`
|
|
||||||
State ContainerState `json:"state"`
|
|
||||||
Deployed bool `json:"deployed"` // Has app.yaml with deployed=true
|
|
||||||
Protected bool `json:"protected"`
|
|
||||||
Containers []ContainerInfo `json:"containers"`
|
|
||||||
AppConfig *AppConfig `json:"app_config,omitempty"`
|
|
||||||
LastUpdated time.Time `json:"last_updated"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manager handles all docker compose stack operations.
|
|
||||||
type Manager struct {
|
|
||||||
cfg *config.Config
|
|
||||||
logger *log.Logger
|
|
||||||
composeCmd string
|
|
||||||
stacks map[string]*Stack
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager creates a new stack manager.
|
|
||||||
func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
|
|
||||||
composeCmd := cfg.Stacks.ComposeCommand
|
|
||||||
if composeCmd == "" {
|
|
||||||
composeCmd = detectComposeCommand()
|
|
||||||
}
|
|
||||||
if composeCmd == "" {
|
|
||||||
return nil, fmt.Errorf("docker compose not found (tried 'docker compose' and 'docker-compose')")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Printf("[INFO] Using compose command: %s", composeCmd)
|
|
||||||
|
|
||||||
if err := os.MkdirAll(cfg.Paths.StacksDir, 0755); err != nil {
|
|
||||||
return nil, fmt.Errorf("creating stacks directory %s: %w", cfg.Paths.StacksDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Manager{
|
|
||||||
cfg: cfg,
|
|
||||||
logger: logger,
|
|
||||||
composeCmd: composeCmd,
|
|
||||||
stacks: make(map[string]*Stack),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// toTitleCase capitalizes the first letter of each word.
|
|
||||||
func toTitleCase(s string) string {
|
|
||||||
words := strings.Fields(s)
|
|
||||||
for i, w := range words {
|
|
||||||
if len(w) > 0 {
|
|
||||||
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(words, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectComposeCommand() string {
|
|
||||||
if err := exec.Command("docker", "compose", "version").Run(); err == nil {
|
|
||||||
return "docker compose"
|
|
||||||
}
|
|
||||||
if _, err := exec.LookPath("docker-compose"); err == nil {
|
|
||||||
return "docker-compose"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScanStacks discovers all compose stacks in the stacks directory.
|
|
||||||
func (m *Manager) ScanStacks() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(m.cfg.Paths.StacksDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading stacks directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name := entry.Name()
|
|
||||||
stackDir := filepath.Join(m.cfg.Paths.StacksDir, name)
|
|
||||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
|
||||||
|
|
||||||
if _, err := os.Stat(composePath); os.IsNotExist(err) {
|
|
||||||
composePath = filepath.Join(stackDir, "docker-compose.yaml")
|
|
||||||
if _, err := os.Stat(composePath); os.IsNotExist(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
found[name] = true
|
|
||||||
|
|
||||||
meta := LoadMetadata(stackDir)
|
|
||||||
appCfg := LoadAppConfig(stackDir)
|
|
||||||
deployed := appCfg != nil && appCfg.Deployed
|
|
||||||
|
|
||||||
if existing, ok := m.stacks[name]; ok {
|
|
||||||
existing.ComposePath = composePath
|
|
||||||
existing.Meta = meta
|
|
||||||
existing.Protected = m.cfg.IsProtectedStack(name)
|
|
||||||
existing.Deployed = deployed
|
|
||||||
existing.AppConfig = appCfg
|
|
||||||
} else {
|
|
||||||
m.stacks[name] = &Stack{
|
|
||||||
Name: name,
|
|
||||||
Meta: meta,
|
|
||||||
ComposePath: composePath,
|
|
||||||
State: StateNotDeployed,
|
|
||||||
Deployed: deployed,
|
|
||||||
Protected: m.cfg.IsProtectedStack(name),
|
|
||||||
AppConfig: appCfg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove stacks no longer on disk
|
|
||||||
for name := range m.stacks {
|
|
||||||
if !found[name] {
|
|
||||||
delete(m.stacks, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Scanned stacks: %d found", len(m.stacks))
|
|
||||||
return m.refreshStatusLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshStatus updates container status for all known stacks.
|
|
||||||
func (m *Manager) RefreshStatus() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return m.refreshStatusLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) refreshStatusLocked() error {
|
|
||||||
output, err := m.execCommand("docker", "ps", "-a",
|
|
||||||
"--format", "{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Label \"com.docker.compose.project\"}}",
|
|
||||||
"--no-trunc")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("docker ps: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
projectContainers := make(map[string][]ContainerInfo)
|
|
||||||
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(line, "\t", 5)
|
|
||||||
if len(parts) < 5 || parts[4] == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ci := ContainerInfo{
|
|
||||||
Name: parts[0],
|
|
||||||
Image: parts[1],
|
|
||||||
State: parseContainerState(parts[2]),
|
|
||||||
Status: parts[3],
|
|
||||||
}
|
|
||||||
projectContainers[parts[4]] = append(projectContainers[parts[4]], ci)
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, stack := range m.stacks {
|
|
||||||
containers, exists := projectContainers[name]
|
|
||||||
if !exists {
|
|
||||||
stack.Containers = nil
|
|
||||||
if stack.Deployed {
|
|
||||||
stack.State = StateStopped
|
|
||||||
} else {
|
|
||||||
stack.State = StateNotDeployed
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stack.Containers = containers
|
|
||||||
stack.State = aggregateState(containers)
|
|
||||||
}
|
|
||||||
stack.LastUpdated = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseContainerState(s string) ContainerState {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
||||||
case "running":
|
|
||||||
return StateRunning
|
|
||||||
case "exited":
|
|
||||||
return StateExited
|
|
||||||
case "restarting":
|
|
||||||
return StateRestarting
|
|
||||||
case "paused":
|
|
||||||
return StatePaused
|
|
||||||
case "created", "dead", "removing":
|
|
||||||
return StateStopped
|
|
||||||
default:
|
|
||||||
return StateUnknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func aggregateState(containers []ContainerInfo) ContainerState {
|
|
||||||
if len(containers) == 0 {
|
|
||||||
return StateNotDeployed
|
|
||||||
}
|
|
||||||
for _, c := range containers {
|
|
||||||
if c.State == StateRunning {
|
|
||||||
return StateRunning
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, c := range containers {
|
|
||||||
if c.State == StateRestarting {
|
|
||||||
return StateRestarting
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return StateStopped
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Stack accessors ---
|
|
||||||
|
|
||||||
func (m *Manager) GetStacks() []Stack {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
result := make([]Stack, 0, len(m.stacks))
|
|
||||||
for _, s := range m.stacks {
|
|
||||||
result = append(result, *s)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) GetStack(name string) (*Stack, bool) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
s, ok := m.stacks[name]
|
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
copy := *s
|
|
||||||
return ©, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Stack operations ---
|
|
||||||
// StartStack, StopStack, etc. now load app.yaml env for deployed stacks.
|
|
||||||
|
|
||||||
func (m *Manager) StartStack(name string) error {
|
|
||||||
stack, ok := m.GetStack(name)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("stack %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Starting stack: %s", name)
|
|
||||||
|
|
||||||
dir := filepath.Dir(stack.ComposePath)
|
|
||||||
env := m.stackEnv(dir)
|
|
||||||
|
|
||||||
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d"); err != nil {
|
|
||||||
return fmt.Errorf("starting stack %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Stack %s started", name)
|
|
||||||
return m.RefreshStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) StopStack(name string) error {
|
|
||||||
if m.cfg.IsProtectedStack(name) {
|
|
||||||
return fmt.Errorf("stack %q is protected and cannot be stopped", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
stack, ok := m.GetStack(name)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("stack %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Stopping stack: %s", name)
|
|
||||||
dir := filepath.Dir(stack.ComposePath)
|
|
||||||
|
|
||||||
if _, err := m.composeExec(dir, "down"); err != nil {
|
|
||||||
return fmt.Errorf("stopping stack %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Stack %s stopped", name)
|
|
||||||
return m.RefreshStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) RestartStack(name string) error {
|
|
||||||
stack, ok := m.GetStack(name)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("stack %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Restarting stack: %s", name)
|
|
||||||
dir := filepath.Dir(stack.ComposePath)
|
|
||||||
|
|
||||||
if _, err := m.composeExec(dir, "restart"); err != nil {
|
|
||||||
return fmt.Errorf("restarting stack %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Stack %s restarted", name)
|
|
||||||
return m.RefreshStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) UpdateStack(name string) error {
|
|
||||||
stack, ok := m.GetStack(name)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("stack %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Updating stack: %s", name)
|
|
||||||
dir := filepath.Dir(stack.ComposePath)
|
|
||||||
env := m.stackEnv(dir)
|
|
||||||
|
|
||||||
if _, err := m.composeExecCustomEnv(dir, env, "pull"); err != nil {
|
|
||||||
return fmt.Errorf("pulling images for %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d", "--remove-orphans"); err != nil {
|
|
||||||
return fmt.Errorf("recreating %s: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Printf("[INFO] Stack %s updated", name)
|
|
||||||
return m.RefreshStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) GetLogs(name string, lines int) (string, error) {
|
|
||||||
stack, ok := m.GetStack(name)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("stack %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lines <= 0 {
|
|
||||||
lines = 100
|
|
||||||
}
|
|
||||||
if lines > 1000 {
|
|
||||||
lines = 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := filepath.Dir(stack.ComposePath)
|
|
||||||
output, err := m.composeExec(dir, "logs", "--tail", fmt.Sprintf("%d", lines), "--no-color")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("getting logs for %s: %w", name, err)
|
|
||||||
}
|
|
||||||
return output, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Env and compose helpers ---
|
|
||||||
|
|
||||||
// stackEnv builds the full OS env slice for a stack, merging app.yaml values.
|
|
||||||
func (m *Manager) stackEnv(stackDir string) []string {
|
|
||||||
env := os.Environ()
|
|
||||||
|
|
||||||
// Always inject DOMAIN
|
|
||||||
env = append(env, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
|
|
||||||
|
|
||||||
// Load app.yaml if it exists — merge its env vars
|
|
||||||
appCfg := LoadAppConfig(stackDir)
|
|
||||||
if appCfg != nil {
|
|
||||||
for k, v := range appCfg.Env {
|
|
||||||
env = append(env, fmt.Sprintf("%s=%s", k, v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) composeExec(dir string, args ...string) (string, error) {
|
|
||||||
return m.composeExecCustomEnv(dir, nil, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string) (string, error) {
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
|
|
||||||
if m.composeCmd == "docker compose" {
|
|
||||||
fullArgs := append([]string{"compose"}, args...)
|
|
||||||
cmd = exec.Command("docker", fullArgs...)
|
|
||||||
} else {
|
|
||||||
cmd = exec.Command("docker-compose", args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Dir = dir
|
|
||||||
|
|
||||||
if env != nil {
|
|
||||||
cmd.Env = env
|
|
||||||
} else {
|
|
||||||
cmd.Env = m.stackEnv(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
m.logger.Printf("[DEBUG] Running: %s %s (in %s)", m.composeCmd, strings.Join(args, " "), dir)
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return stdout.String(), fmt.Errorf("%w\nstderr: %s", err, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
return stdout.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) execCommand(name string, args ...string) (string, error) {
|
|
||||||
cmd := exec.Command(name, args...)
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return "", fmt.Errorf("exec %s %s: %w\nstderr: %s", name, strings.Join(args, " "), err, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
return stdout.String(), nil
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package stacks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Metadata holds app information parsed from .felhom.yml.
|
|
||||||
type Metadata struct {
|
|
||||||
DisplayName string `yaml:"display_name" json:"display_name"`
|
|
||||||
Description string `yaml:"description" json:"description"`
|
|
||||||
Category string `yaml:"category" json:"category"`
|
|
||||||
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
|
||||||
Slug string `yaml:"slug" json:"slug"`
|
|
||||||
Resources ResourceHints `yaml:"resources" json:"resources"`
|
|
||||||
DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResourceHints describe what the app needs.
|
|
||||||
type ResourceHints struct {
|
|
||||||
RAM string `yaml:"ram" json:"ram"`
|
|
||||||
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
|
|
||||||
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeployField defines one configuration field shown during first deployment.
|
|
||||||
type DeployField struct {
|
|
||||||
EnvVar string `yaml:"env_var" json:"env_var"`
|
|
||||||
Label string `yaml:"label" json:"label"`
|
|
||||||
Type string `yaml:"type" json:"type"` // domain, secret, password, path, text, select, boolean
|
|
||||||
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
|
|
||||||
Default string `yaml:"default" json:"default"`
|
|
||||||
Required bool `yaml:"required" json:"required"`
|
|
||||||
Placeholder string `yaml:"placeholder" json:"placeholder"`
|
|
||||||
Description string `yaml:"description" json:"description"`
|
|
||||||
LockedAfterDeploy bool `yaml:"locked_after_deploy" json:"locked_after_deploy"`
|
|
||||||
Options []SelectOption `yaml:"options" json:"options,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SelectOption is a choice for "select" type fields.
|
|
||||||
type SelectOption struct {
|
|
||||||
Value string `yaml:"value" json:"value"`
|
|
||||||
Label string `yaml:"label" json:"label"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadMetadata reads .felhom.yml from a stack directory.
|
|
||||||
// Returns default metadata if the file doesn't exist.
|
|
||||||
func LoadMetadata(stackDir string) Metadata {
|
|
||||||
meta := Metadata{}
|
|
||||||
|
|
||||||
path := filepath.Join(stackDir, ".felhom.yml")
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
// No metadata file — build defaults from directory name
|
|
||||||
dirName := filepath.Base(stackDir)
|
|
||||||
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
|
||||||
meta.Slug = dirName
|
|
||||||
meta.Category = "tools"
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := yaml.Unmarshal(data, &meta); err != nil {
|
|
||||||
// Parse error — still return defaults
|
|
||||||
dirName := filepath.Base(stackDir)
|
|
||||||
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
|
||||||
meta.Slug = dirName
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill in defaults for missing fields
|
|
||||||
dirName := filepath.Base(stackDir)
|
|
||||||
if meta.Slug == "" {
|
|
||||||
meta.Slug = dirName
|
|
||||||
}
|
|
||||||
if meta.DisplayName == "" {
|
|
||||||
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
|
||||||
}
|
|
||||||
if meta.Category == "" {
|
|
||||||
meta.Category = "tools"
|
|
||||||
}
|
|
||||||
|
|
||||||
// DOMAIN field is always auto-filled — mark it implicitly required
|
|
||||||
for i := range meta.DeployFields {
|
|
||||||
if meta.DeployFields[i].Type == "domain" {
|
|
||||||
meta.DeployFields[i].Required = true
|
|
||||||
meta.DeployFields[i].LockedAfterDeploy = true
|
|
||||||
}
|
|
||||||
// secret fields are always locked after deploy
|
|
||||||
if meta.DeployFields[i].Type == "secret" {
|
|
||||||
meta.DeployFields[i].LockedAfterDeploy = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasDeployFields returns true if the app has any user-facing deploy fields
|
|
||||||
// (i.e., fields beyond auto-filled domain and auto-generated secrets).
|
|
||||||
func (m *Metadata) HasDeployFields() bool {
|
|
||||||
for _, f := range m.DeployFields {
|
|
||||||
if f.Type != "domain" && f.Type != "secret" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserFacingFields returns only fields the user needs to interact with.
|
|
||||||
// Excludes auto-filled (domain) and fully hidden (secret) fields.
|
|
||||||
func (m *Metadata) UserFacingFields() []DeployField {
|
|
||||||
var fields []DeployField
|
|
||||||
for _, f := range m.DeployFields {
|
|
||||||
if f.Type != "domain" && f.Type != "secret" {
|
|
||||||
fields = append(fields, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fields
|
|
||||||
}
|
|
||||||
|
|
||||||
// AutoGeneratedFields returns fields that are generated without user input.
|
|
||||||
func (m *Metadata) AutoGeneratedFields() []DeployField {
|
|
||||||
var fields []DeployField
|
|
||||||
for _, f := range m.DeployFields {
|
|
||||||
if f.Type == "secret" || f.Type == "domain" {
|
|
||||||
fields = append(fields, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fields
|
|
||||||
}
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
# felhom-controller
|
|
||||||
|
|
||||||
**Central management container for Felhom home servers.**
|
|
||||||
|
|
||||||
Replaces Portainer + scattered systemd scripts with a single, lightweight container that provides:
|
|
||||||
- Hungarian-language web dashboard for customers
|
|
||||||
- Docker Compose stack management (start/stop/update)
|
|
||||||
- Backup orchestration (DB dumps + restic snapshots)
|
|
||||||
- System health monitoring with Healthchecks pings
|
|
||||||
- Git-based stack synchronization with update management
|
|
||||||
- Self-update with automatic rollback on failure
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Customer Hardware (N100 mini PC / Raspberry Pi) │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────┐ ┌────────────────────────────────────────────┐ │
|
|
||||||
│ │ Traefik │ │ felhom-controller │ │
|
|
||||||
│ │ (reverse │──▶│ │ │
|
|
||||||
│ │ proxy) │ │ ┌──────────┐ ┌─────────────────────────┐│ │
|
|
||||||
│ └──────────┘ │ │ Web UI │ │ Stack Manager ││ │
|
|
||||||
│ │ │ (HU dash │ │ (compose up/down/pull, ││ │
|
|
||||||
│ ┌──────────┐ │ │ board) │ │ git sync, update mgmt) ││ │
|
|
||||||
│ │cloudflared│ │ └──────────┘ └─────────────────────────┘│ │
|
|
||||||
│ │ (tunnel) │ │ ┌──────────┐ ┌─────────────────────────┐│ │
|
|
||||||
│ └──────────┘ │ │ Backup │ │ Monitor & Pinger ││ │
|
|
||||||
│ │ │ (db dump │ │ (healthchecks pings, ││ │
|
|
||||||
│ ┌──────────┐ │ │ restic) │ │ system metrics) ││ │
|
|
||||||
│ │ App │ │ └──────────┘ └─────────────────────────┘│ │
|
|
||||||
│ │ stacks │ │ ┌──────────┐ ┌─────────────────────────┐│ │
|
|
||||||
│ │ (docker │ │ │Scheduler │ │ REST API ││ │
|
|
||||||
│ │ compose) │ │ │(cron-like│ │ (for UI + remote mgmt) ││ │
|
|
||||||
│ └──────────┘ │ │ jobs) │ └─────────────────────────┘│ │
|
|
||||||
│ │ └──────────┘ │ │
|
|
||||||
│ └────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
│ pings │ git pull
|
|
||||||
▼ ▼
|
|
||||||
status.felhom.eu gitea.dooplex.hu
|
|
||||||
(Healthchecks on k3s) (stack definitions)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Module Overview
|
|
||||||
|
|
||||||
| Module | Path | Responsibility |
|
|
||||||
|--------|------|----------------|
|
|
||||||
| **Config** | `internal/config/` | Load & validate controller.yaml |
|
|
||||||
| **Stacks** | `internal/stacks/` | Docker Compose operations, catalog, container status |
|
|
||||||
| **Backup** | `internal/backup/` | DB dumps, restic snapshots, restore |
|
|
||||||
| **Monitor** | `internal/monitor/` | Health checks, Healthchecks pings, system metrics |
|
|
||||||
| **Scheduler** | `internal/scheduler/` | Cron-like job runner for all periodic tasks |
|
|
||||||
| **API** | `internal/api/` | REST API endpoints (consumed by web UI + remote mgmt) |
|
|
||||||
| **Web** | `internal/web/` | Dashboard UI, static files, server-side templates |
|
|
||||||
|
|
||||||
## Stack Management
|
|
||||||
|
|
||||||
### How stacks get onto the machine
|
|
||||||
|
|
||||||
1. During initial setup, `deploy-felhom-compose.sh` clones the app catalog
|
|
||||||
2. Compose files + `.felhom.yml` metadata land in `/opt/docker/stacks/<app>/`
|
|
||||||
3. The controller periodically pulls from Git to detect changes
|
|
||||||
|
|
||||||
### First deployment flow (via dashboard)
|
|
||||||
|
|
||||||
1. Customer sees app card with "🚀 Telepítés" (Deploy) button
|
|
||||||
2. Clicks → deploy page shows:
|
|
||||||
- **Auto-filled**: DOMAIN (from controller config), read-only
|
|
||||||
- **Auto-generated**: DB passwords, secret keys (shown as "✓ Generated")
|
|
||||||
- **User input**: HDD path, admin password, language, etc.
|
|
||||||
- **"🎲 Generálás"** button next to password fields
|
|
||||||
3. Clicks "Telepítés" → controller:
|
|
||||||
- Generates all secrets
|
|
||||||
- Validates required fields (checks path exists, etc.)
|
|
||||||
- Saves `app.yaml` (env vars + locked fields list)
|
|
||||||
- Runs `docker compose up -d` with env vars injected
|
|
||||||
4. Post-deploy: locked fields (DB_PASSWORD, etc.) become read-only
|
|
||||||
|
|
||||||
### Update strategy
|
|
||||||
|
|
||||||
Stack updates are classified in the Git repository via markers:
|
|
||||||
|
|
||||||
| Marker | Behavior |
|
|
||||||
|--------|----------|
|
|
||||||
| No marker | Optional update — shown on dashboard, customer clicks "Update" |
|
|
||||||
| `UPDATE_REQUIRED=true` | Mandatory — auto-applied during next update window |
|
|
||||||
| `UPDATE_SECURITY=true` | Critical — applied immediately (within minutes) |
|
|
||||||
|
|
||||||
The update window is configurable per customer (default: 03:00-05:00 local time).
|
|
||||||
|
|
||||||
### Protected stacks
|
|
||||||
|
|
||||||
The following stacks cannot be stopped from the customer UI:
|
|
||||||
- `traefik` (reverse proxy)
|
|
||||||
- `cloudflared` (tunnel)
|
|
||||||
- `felhom-controller` (this container)
|
|
||||||
|
|
||||||
## Backup Strategy
|
|
||||||
|
|
||||||
The controller replaces Backrest and manages backups directly:
|
|
||||||
|
|
||||||
1. **DB dumps** (default 02:30): Discovers running database containers, dumps via pg_dump/mysqldump
|
|
||||||
2. **Restic snapshots** (default 03:00): Backs up `/opt/docker/stacks/` data + DB dumps
|
|
||||||
3. **Verification**: Periodically checks snapshot integrity
|
|
||||||
4. **Pruning**: Configurable retention (default: 7 daily, 4 weekly, 6 monthly)
|
|
||||||
|
|
||||||
Backup status is displayed on the dashboard and reported to Healthchecks.
|
|
||||||
|
|
||||||
## Self-Update Mechanism
|
|
||||||
|
|
||||||
1. Controller checks for new image versions periodically
|
|
||||||
2. Before updating: creates a restic snapshot of its own config
|
|
||||||
3. Pulls new image, recreates container
|
|
||||||
4. Health check timeout (60s) — if new container doesn't become healthy → rollback
|
|
||||||
5. Rollback: restores previous image tag, restarts with old config
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Controller config (infrastructure only)
|
|
||||||
|
|
||||||
Single YAML file per customer: `/opt/docker/felhom-controller/controller.yaml`
|
|
||||||
|
|
||||||
Contains customer identity, infrastructure secrets, backup/monitoring settings.
|
|
||||||
Does **not** contain app-specific config (HDD paths, DB passwords, etc.).
|
|
||||||
|
|
||||||
See `configs/controller.yaml.example` for the full reference.
|
|
||||||
|
|
||||||
### Per-app config (created during deployment)
|
|
||||||
|
|
||||||
Each deployed app gets an `app.yaml` in its stack directory:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# /opt/docker/stacks/paperless-ngx/app.yaml
|
|
||||||
# Auto-generated by felhom-controller — do not edit locked fields manually
|
|
||||||
deployed: true
|
|
||||||
deployed_at: "2026-02-13T14:30:00Z"
|
|
||||||
env:
|
|
||||||
DOMAIN: "demo-felhom.eu"
|
|
||||||
DB_PASSWORD: "a7f2b9c1e4d..." # locked
|
|
||||||
PAPERLESS_SECRET_KEY: "8b3e..." # locked
|
|
||||||
PAPERLESS_ADMIN_USER: "admin" # editable
|
|
||||||
HDD_PATH: "/mnt/hdd_1" # locked
|
|
||||||
locked_fields:
|
|
||||||
- DB_PASSWORD
|
|
||||||
- PAPERLESS_SECRET_KEY
|
|
||||||
- DOMAIN
|
|
||||||
- HDD_PATH
|
|
||||||
```
|
|
||||||
|
|
||||||
Fields are defined in each stack's `.felhom.yml` metadata file. See
|
|
||||||
`configs/example-felhom-metadata.yml` for the full format.
|
|
||||||
|
|
||||||
### App assets (logos, screenshots, descriptions)
|
|
||||||
|
|
||||||
Baked into the container image at build time — no external dependencies at runtime.
|
|
||||||
Assets are synced from the felhom.eu website repo before building:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make sync-assets # copies from ../felhom.eu/website/assets/
|
|
||||||
make sync-assets WEBSITE_ASSETS_DIR=/path # or specify custom path
|
|
||||||
```
|
|
||||||
|
|
||||||
Served locally at `/static/assets/`. Naming convention matches the website:
|
|
||||||
|
|
||||||
| Asset | File pattern | Served at |
|
|
||||||
|-------|-------------|-----------|
|
|
||||||
| Logo (SVG) | `assets/{slug}-logo.svg` | `/static/assets/{slug}-logo.svg` |
|
|
||||||
| Logo (PNG fallback) | `assets/{slug}-logo.png` | `/static/assets/{slug}-logo.png` |
|
|
||||||
| Screenshot | `assets/{slug}-screenshot-{n}.webp` | `/static/assets/{slug}-screenshot-{n}.webp` |
|
|
||||||
|
|
||||||
## Build & Deploy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build for both architectures
|
|
||||||
make build-all
|
|
||||||
|
|
||||||
# Build Docker image
|
|
||||||
make docker-build
|
|
||||||
|
|
||||||
# Push to registry
|
|
||||||
make docker-push
|
|
||||||
|
|
||||||
# Build for specific arch
|
|
||||||
make build-amd64
|
|
||||||
make build-arm64
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run locally (needs Docker socket)
|
|
||||||
go run ./cmd/controller/ --config configs/controller.yaml.example
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
# Lint
|
|
||||||
golangci-lint run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Repository Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
felhom-controller/
|
|
||||||
├── cmd/controller/ # Entry point
|
|
||||||
│ └── main.go
|
|
||||||
├── internal/
|
|
||||||
│ ├── config/ # Configuration loading
|
|
||||||
│ │ └── config.go
|
|
||||||
│ ├── stacks/ # Docker Compose stack management
|
|
||||||
│ │ ├── manager.go # Core: scan, start, stop, restart, update, logs
|
|
||||||
│ │ ├── metadata.go # Parse .felhom.yml app metadata
|
|
||||||
│ │ └── deploy.go # First-deploy flow: secret gen, app.yaml, compose up
|
|
||||||
│ ├── backup/ # DB dumps + restic operations (Phase 3)
|
|
||||||
│ ├── monitor/ # Health checks + metrics (Phase 2)
|
|
||||||
│ ├── scheduler/ # Periodic job runner (Phase 2)
|
|
||||||
│ ├── api/ # REST API
|
|
||||||
│ │ └── router.go
|
|
||||||
│ └── web/ # Dashboard UI
|
|
||||||
│ ├── server.go # HTTP server, auth, page handlers
|
|
||||||
│ └── templates.go # Embedded HTML templates + CSS (Hungarian)
|
|
||||||
├── configs/ # Example config files
|
|
||||||
│ ├── controller.yaml.example
|
|
||||||
│ └── example-felhom-metadata.yml
|
|
||||||
├── docs/
|
|
||||||
│ └── BUILDING.md # Container image build & registry guide
|
|
||||||
├── scripts/
|
|
||||||
│ └── hashpass.go # Password hash generator
|
|
||||||
├── Dockerfile # Multi-stage build (Go + debian-slim)
|
|
||||||
├── docker-compose.yml # Controller's own compose definition
|
|
||||||
├── Makefile # Build targets (amd64, arm64, docker)
|
|
||||||
├── go.mod
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Status & Roadmap
|
|
||||||
|
|
||||||
### Phase 1 — Stack Manager + Deploy Flow (current)
|
|
||||||
- [x] Project skeleton & config format
|
|
||||||
- [x] .felhom.yml app metadata format with deploy fields
|
|
||||||
- [x] Per-app config persistence (app.yaml)
|
|
||||||
- [x] Secret generation engine (password, hex, static)
|
|
||||||
- [x] Stack catalog (read compose files + metadata from disk)
|
|
||||||
- [x] Docker Compose operations (up/down/pull/ps/logs)
|
|
||||||
- [x] Deploy flow with interactive field input
|
|
||||||
- [x] Basic web dashboard with start/stop/deploy buttons
|
|
||||||
- [x] REST API for stack + deploy operations
|
|
||||||
- [x] Simple web authentication (bcrypt sessions)
|
|
||||||
- [x] App logos + screenshots loaded from felhom.eu
|
|
||||||
- [x] Container image build pipeline (Dockerfile + Makefile)
|
|
||||||
- [ ] First build & test on N100 hardware
|
|
||||||
- [ ] End-to-end test: deploy an app through dashboard
|
|
||||||
|
|
||||||
### Phase 2 — Monitoring & Health
|
|
||||||
- [ ] System metrics collection (CPU, RAM, disk, temperature)
|
|
||||||
- [ ] Healthchecks.io ping integration
|
|
||||||
- [ ] Dashboard system health panel
|
|
||||||
- [ ] Customer notifications (email/Telegram)
|
|
||||||
|
|
||||||
### Phase 3 — Backups
|
|
||||||
- [ ] DB dump engine (PostgreSQL, MariaDB/MySQL, SQLite)
|
|
||||||
- [ ] Restic integration (snapshot, prune, check)
|
|
||||||
- [ ] Backup status on dashboard
|
|
||||||
- [ ] Manual backup trigger from UI
|
|
||||||
- [ ] Restore workflow
|
|
||||||
|
|
||||||
### Phase 4 — Git Sync & Updates
|
|
||||||
- [ ] Periodic git pull for stack definitions
|
|
||||||
- [ ] Update classification (optional/required/security)
|
|
||||||
- [ ] Update window enforcement
|
|
||||||
- [ ] Dashboard update notifications with "Update" button
|
|
||||||
|
|
||||||
### Phase 5 — Self-Update & Resilience
|
|
||||||
- [ ] Self-update check & execution
|
|
||||||
- [ ] Pre-update config backup
|
|
||||||
- [ ] Health-based rollback mechanism
|
|
||||||
- [ ] Config export/import
|
|
||||||
|
|
||||||
### Phase 6 — Central Management (future)
|
|
||||||
- [ ] API authentication for remote management
|
|
||||||
- [ ] Central dashboard on k3s querying all customer controllers
|
|
||||||
- [ ] Fleet-wide update management
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# App Assets
|
|
||||||
|
|
||||||
This directory contains logos and screenshots for the dashboard.
|
|
||||||
They are baked into the Docker image at build time.
|
|
||||||
|
|
||||||
## Naming convention
|
|
||||||
|
|
||||||
Files must follow the felhom.eu website convention:
|
|
||||||
|
|
||||||
- `{slug}-logo.svg` — App logo (SVG preferred, displayed on dark background)
|
|
||||||
- `{slug}-logo.png` — App logo fallback (PNG, for apps without SVG)
|
|
||||||
- `{slug}-screenshot-1.webp` — First screenshot
|
|
||||||
- `{slug}-screenshot-2.webp` — Second screenshot (and so on)
|
|
||||||
|
|
||||||
The dashboard tries SVG first, falls back to PNG if not found.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```
|
|
||||||
paperless-ngx-logo.svg
|
|
||||||
paperless-ngx-screenshot-1.webp
|
|
||||||
adventurelog-logo.png
|
|
||||||
adventurelog-screenshot-1.webp
|
|
||||||
```
|
|
||||||
|
|
||||||
## Syncing from felhom.eu website
|
|
||||||
|
|
||||||
Run `make sync-assets` to copy assets from the felhom.eu website repo.
|
|
||||||
This expects the website files to be available at `../felhom.eu/website/assets/`
|
|
||||||
(relative to this repo), or set `WEBSITE_ASSETS_DIR` to override.
|
|
||||||
|
|
||||||
Alternatively, copy files manually from FileBrowser at https://felhom.eu.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Actual Budget</title><path d="m17.442 10.779.737 2.01-16.758 6.145a.253.253 0 0 1-.324-.15l-.563-1.536a.253.253 0 0 1 .15-.324zM1.13 23.309 12.036.145A.253.253 0 0 1 12.265 0h.478c.097 0 .185.055.227.142l7.036 14.455 2.206-.848c.13-.05.277.015.327.145l.587 1.526a.253.253 0 0 1-.145.327l-2.034.783 2.51 5.156a.253.253 0 0 1-.117.338l-1.47.716a.253.253 0 0 1-.339-.117l-2.59-5.322-17.37 6.682a.253.253 0 0 1-.328-.145c0-.001 0-.003-.002-.004l-.12-.33a.252.252 0 0 1 .009-.195zM12.528 4.127 4.854 20.425 18 15.369z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 614 B |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 242 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Audiobookshelf</title><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0Zm-.023.402A11.598 11.598 0 0 1 23.575 12a11.598 11.598 0 0 1-11.598 11.598A11.598 11.598 0 0 1 .378 12 11.598 11.598 0 0 1 11.977.402Zm0 1.776a7.093 7.093 0 0 0-7.092 7.093v1.536a6.395 6.395 0 0 0-.439.33.35.35 0 0 0-.126.27v1.84a.36.36 0 0 0 .126.272c.22.182.722.564 1.504.956v.179c0 .483.31.873.694.873.384 0 .694-.392.694-.873v-4.415c0-.483-.31-.873-.694-.873-.369 0-.67.359-.694.812h-.002v-.91a6.027 6.027 0 1 1 12.054.003v.91c-.025-.454-.326-.813-.695-.813-.384 0-.694.391-.694.873v4.415c0 .483.31.873.694.873.384 0 .695-.392.695-.873v-.179a7.964 7.964 0 0 0 1.503-.956.35.35 0 0 0 .126-.272v-1.843a.342.342 0 0 0-.124-.27 5.932 5.932 0 0 0-.438-.329V9.271a7.093 7.093 0 0 0-7.092-7.093zm-3.34 5.548a.84.84 0 0 0-.84.84v9.405c0 .464.376.84.84.84h.866a.84.84 0 0 0 .84-.84V8.566a.84.84 0 0 0-.84-.84Zm2.905 0a.84.84 0 0 0-.84.84v9.405c0 .464.377.84.84.84h.867a.84.84 0 0 0 .84-.84V8.566a.84.84 0 0 0-.84-.84zm2.908 0a.84.84 0 0 0-.84.84v9.405c0 .464.376.84.84.84h.867a.84.84 0 0 0 .84-.84V8.566a.84.84 0 0 0-.84-.84zM8.112 9.983h1.915v.2H8.112Zm2.906 0h1.915v.2h-1.915Zm2.908 0h1.915v.2h-1.915zm-7.58 9.119a.633.633 0 0 0 0 1.265h11.26a.632.632 0 0 0 0-1.265z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 423 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 132 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<!--Circular Background-->
|
|
||||||
<g transform="translate(48, 12) scale(0.85)">
|
|
||||||
<path d="M56.7848 80.9116L0 25.6211L24.8434 1.43151C26.8037 -0.47717 29.9814 -0.47717 31.9414 1.43151L85.177 53.2662L56.7848 80.9116Z" fill="white"/>
|
|
||||||
<path d="M-1.30805e-05 80.7335L21.2939 101.467L42.5878 80.7335L21.2939 60L-1.30805e-05 80.7335Z" fill="white"/>
|
|
||||||
<path d="M56.7848 83L81.6279 107.19C83.5881 109.098 83.5881 112.192 81.6279 114.101L28.3925 165.936L0 138.29L56.7848 83Z" fill="white"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 600 B |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 61 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>BookStack</title><path d="M.3013 17.6146c-.1299-.3387-.5228-1.5119-.1337-2.4314l9.8273 5.6738a.329.329 0 0 0 .3299 0L24 12.9616v2.3542l-13.8401 7.9906-9.8586-5.6918zM.1911 8.9628c-.2882.8769.0149 2.0581.1236 2.4261l9.8452 5.6841L24 9.0823V6.7275L10.3248 14.623a.329.329 0 0 1-.3299 0L.1911 8.9628zm13.1698-1.9361c-.1819.1113-.4394.0015-.4852-.2064l-.2805-1.1336-2.1254-.1752a.33.33 0 0 1-.1378-.6145l5.5782-3.2207-1.7021-.9826L.6979 8.4935l9.462 5.463 13.5104-7.8004-4.401-2.5407-5.9084 3.4113zm-.1821-1.7286.2321.938 5.1984-3.0014-2.0395-1.1775-4.994 2.8834 1.3099.108a.3302.3302 0 0 1 .2931.2495zM24 9.845l-13.6752 7.8954a.329.329 0 0 1-.3299 0L.1678 12.0667c-.3891.919.003 2.0914.1332 2.4311l9.8589 5.692L24 12.1993V9.845z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 827 B |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 73 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Cal.com</title><path d="M2.408 14.488C1.035 14.488 0 13.4 0 12.058c0-1.346.982-2.443 2.408-2.443.758 0 1.282.233 1.691.765l-.66.55a1.343 1.343 0 0 0-1.03-.442c-.93 0-1.44.711-1.44 1.57 0 .86.559 1.557 1.44 1.557.413 0 .765-.147 1.043-.443l.651.573c-.391.51-.929.743-1.695.743zM6.948 10.913h.89v3.49h-.89v-.51c-.185.362-.493.604-1.083.604-.943 0-1.695-.82-1.695-1.826 0-1.007.752-1.825 1.695-1.825.585 0 .898.241 1.083.604zm.026 1.758c0-.546-.374-.998-.964-.998-.568 0-.938.457-.938.998 0 .528.37.998.938.998.586 0 .964-.456.964-.998zM8.467 9.503h.89v4.895h-.89zM9.752 13.937a.53.53 0 0 1 .542-.528c.313 0 .533.242.533.528a.527.527 0 0 1-.533.537.534.534 0 0 1-.542-.537zM14.23 13.839c-.33.403-.832.658-1.426.658a1.806 1.806 0 0 1-1.84-1.826c0-1.007.778-1.825 1.84-1.825.572 0 1.07.241 1.4.622l-.687.577c-.172-.215-.396-.376-.713-.376-.568 0-.938.456-.938.998 0 .541.37.997.938.997.343 0 .58-.179.757-.42zM14.305 12.671c0-1.007.78-1.825 1.84-1.825 1.061 0 1.84.818 1.84 1.825 0 1.007-.779 1.826-1.84 1.826-1.06-.005-1.84-.82-1.84-1.826zm2.778 0c0-.546-.37-.998-.938-.998-.568-.004-.937.452-.937.998 0 .542.37.998.937.998.568 0 .938-.456.938-.998zM24 12.269v2.13h-.89v-1.911c0-.604-.281-.864-.704-.864-.396 0-.678.197-.678.864v1.91h-.89v-1.91c0-.604-.285-.864-.704-.864-.396 0-.744.197-.744.864v1.91h-.89v-3.49h.89v.484c.185-.376.52-.564 1.035-.564.489 0 .898.241 1.123.649.224-.417.554-.65 1.153-.65.731.005 1.299.56 1.299 1.442z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Calibre-Web</title><path d="M13.736.083q4.9353-.6785 5.5252 4.1915-1.104 5.4862-6.4778 7.1446-1.3131.3981-2.6673.1905-.409-.133-.6668-.4763a3.91 3.91 0 0 1 0-1.7147q4.0727.4425 6.4778-3.0484.8668-1.3161.5715-2.8578-.5576-1.2044-1.9052-1.1432-2.7075.4504-4.382 2.6674-3.9135 5.7548-2.4768 12.5745 1.59 5.4391 6.954 3.5246 1.458-.7474 2.6674-1.81 1.627.6834.8573 2.2864-4.452 3.9011-9.8119 1.4289-3.1384-2.512-3.5247-6.573-.858-7.33 3.62-13.1462Q10.673.9268 13.736.083"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 568 B |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 232 KiB |
@@ -1,2 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" enable-background="new 0 0 64 64"><path d="M32,2C15.431,2,2,15.432,2,32c0,16.568,13.432,30,30,30c16.568,0,30-13.432,30-30C62,15.432,48.568,2,32,2z M25.025,50
|
|
||||||
l-0.02-0.02L24.988,50L11,35.6l7.029-7.164l6.977,7.184l21-21.619L53,21.199L25.025,50z" fill="#43a047"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 328 B |
@@ -1,2 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" enable-background="new 0 0 64 64"><path d="M32,2C15.431,2,2,15.432,2,32c0,16.568,13.432,30,30,30c16.568,0,30-13.432,30-30C62,15.432,48.568,2,32,2z M25.025,50
|
|
||||||
l-0.02-0.02L24.988,50L11,35.6l7.029-7.164l6.977,7.184l21-21.619L53,21.199L25.025,50z" fill="#fdd835"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 328 B |
@@ -1,13 +0,0 @@
|
|||||||
<svg id="Calque_1" data-name="Calque 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.91 28.98">
|
|
||||||
<defs>
|
|
||||||
<style>
|
|
||||||
.cls-1{fill:#ffffff;}.cls-2,.cls-3,.cls-4{fill:none;stroke-linecap:round;stroke-miterlimit:10;stroke-width:4px;}.cls-2{stroke:#ffffff;}.cls-3{stroke:#ffffff;}.cls-4{stroke:#ffffff;}
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<g id="Logo">
|
|
||||||
<path stroke="white" fill="white" d="M6.8,23.35a2.78,2.78,0,0,1-4.4-.3A15.42,15.42,0,0,1,0,15.85,16.69,16.69,0,0,1,.5,10c.4-1.4,1.4-2.2,2.7-1.9a2.25,2.25,0,0,1,1.6,3,13.1,13.1,0,0,0,.1,6.8c2.9-3.8,5.6-7.3,8.3-10.9,1.5-2,3-4,4.5-5.9a2.53,2.53,0,0,1,3.5-.4,2,2,0,0,1,.6,3.1Z"/>
|
|
||||||
<line class="cls-2" x1="10.4" y1="25.55" x2="26.7" y2="4.65"/>
|
|
||||||
<line class="cls-3" x1="17.2" y1="26.85" x2="29.7" y2="11.05"/>
|
|
||||||
<line class="cls-4" x1="25.2" y1="26.45" x2="31.5" y2="18.05"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 18 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Coder</title><path d="M14.862 6.67H24v10.663h-9.138zM6.945 15.304c-1.934 0-3.366-1.264-3.366-3.305s1.432-3.323 3.366-3.365c1.411-.03 2.787.99 2.878 2.543l3.472-.106c-.076-2.802-2.33-4.706-6.35-4.706S0 8.558 0 12c0 3.426 3.046 5.635 6.945 5.635 3.898 0 6.29-1.935 6.38-4.782l-3.472-.077c-.152 1.553-1.497 2.528-2.908 2.528Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 424 B |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 44 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Emby</title><path d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513.512.512-2.819 2.791L0 12.961l1.83 1.848c1.006 1.016 2.438 2.46 3.182 3.209l1.351 1.359.508-.496c.28-.273.515-.498.524-.498.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516-.502-.513c-.276-.282-.5-.52-.496-.53.003-.009 1.264-1.26 2.802-2.783 1.538-1.522 2.8-2.776 2.803-2.785.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497-.013-.005-1.27-1.267-2.793-2.805A449.655 449.655 0 0011.041 0zM9.223 7.367c.091.038 7.951 4.608 7.957 4.627.003.013-1.781 1.056-3.965 2.32a999.898 999.898 0 01-3.996 2.307c-.019.006-.026-1.266-.026-4.629 0-3.7.007-4.634.03-4.625Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 810 B |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,21 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="560" height="560" version="1.1" id="prefix__svg44" clip-rule="evenodd" fill-rule="evenodd" image-rendering="optimizeQuality" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
||||||
<defs id="prefix__defs4">
|
|
||||||
<style type="text/css" id="style2">
|
|
||||||
.prefix__fil1{fill:#fefefe}.prefix__fil6{fill:#006498}.prefix__fil5{fill:#bdeaff}
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<g id="prefix__g85" transform="translate(-70 -70)">
|
|
||||||
<path d="M231 211h208l38 24v246c0 5-3 8-8 8H231c-5 0-8-3-8-8V219c0-5 3-8 8-8z" id="prefix__path13" fill="#ffffff"/>
|
|
||||||
<path d="M231 211h208l38 24v2l-37-23H231c-4 0-7 3-7 7v263c-1-1-1-2-1-3V219c0-5 3-8 8-8z" id="prefix__path15" fill="#ffffff"/>
|
|
||||||
<path id="prefix__polygon17" fill="#1c2128" d="M305 212h113v98H305z"/>
|
|
||||||
<path d="M255 363h189c3 0 5 2 5 4v116H250V367c0-2 2-4 5-4z" id="prefix__path19" fill="#1c2128"/>
|
|
||||||
<path id="prefix__polygon21" fill="#1c2128" d="M250 470h199v13H250z"/>
|
|
||||||
<path d="M380 226h10c3 0 6 2 6 5v40c0 3-3 6-6 6h-10c-3 0-6-3-6-6v-40c0-3 3-5 6-5z" id="prefix__path23" fill="#ffffff"/>
|
|
||||||
<path d="M267 448h165c2 0 3 1 3 3 0 1-1 3-3 3H267c-2 0-3-2-3-3 0-2 1-3 3-3z" id="prefix__path27" fill="#ffffff"/>
|
|
||||||
<path d="M267 415h165c2 0 3 1 3 3 0 1-1 2-3 2H267c-2 0-3-1-3-2 0-2 1-3 3-3z" id="prefix__path29" fill="#ffffff"/>
|
|
||||||
<path d="M267 381h165c2 0 3 2 3 3 0 2-1 3-3 3H267c-2 0-3-1-3-3 0-1 1-3 3-3z" id="prefix__path31" fill="#ffffff"/>
|
|
||||||
<path id="prefix__polygon37" fill="#1c2128" d="M305 212h-21v98h21z"/>
|
|
||||||
<path d="M477 479v2c0 5-3 8-8 8H231c-5 0-8-3-8-8v-2c0 4 3 8 8 8h238c5 0 8-4 8-8z" id="prefix__path39" fill="#ffffff"/>
|
|
||||||
<path d="M350 70c155 0 280 125 280 280S505 630 350 630 70 505 70 350 195 70 350 70zm0 46c129 0 234 105 234 234S479 584 350 584 116 479 116 350s105-234 234-234z" id="prefix__path41" fill="#ffffff"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 41 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<title>Ghost</title>
|
|
||||||
<path fill="white" d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm.256 2.313c2.47.005 5.116 2.008 5.898 2.962l.244.3c1.64 1.994 3.569 4.34 3.569 6.966 0 3.719-2.98 5.808-6.158 7.508-1.433.766-2.98 1.508-4.748 1.508-4.543 0-8.366-3.569-8.366-8.112 0-.706.17-1.425.342-2.15.122-.515.244-1.033.307-1.549.548-4.539 2.967-6.795 8.422-7.408a4.29 4.29 0 01.49-.026Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 494 B |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 75 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gitea</title><path d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" fill="#ffffff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 837 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Glance</title><path d="M2.77 0A2.763 2.763 0 0 0 0 2.77v18.46A2.763 2.763 0 0 0 2.77 24h18.46A2.763 2.763 0 0 0 24 21.23V2.77A2.763 2.763 0 0 0 21.23 0Zm.922 1.846h5.539c1.023 0 1.846.824 1.846 1.846v16.616a1.842 1.842 0 0 1-1.846 1.846H3.692a1.842 1.842 0 0 1-1.846-1.846V3.692c0-1.022.824-1.846 1.846-1.846zm11.077 0h5.539c1.022 0 1.846.824 1.846 1.846v5.539a1.842 1.842 0 0 1-1.846 1.846h-5.539a1.842 1.842 0 0 1-1.846-1.846V3.692c0-1.022.823-1.846 1.846-1.846zm1.226 1.846-.946.961h2.964c.148 0 .29-.005.423-.012a.78.78 0 0 0 .312-.089L14.77 8.528l.725.703 3.923-3.941a1.031 1.031 0 0 0-.1.322 3.265 3.265 0 0 0-.023.38v3.071l1.014-1.004V3.692Zm-1.226 9.231h5.539c1.022 0 1.846.823 1.846 1.846v5.539a1.842 1.842 0 0 1-1.846 1.846h-5.539a1.842 1.842 0 0 1-1.846-1.846v-5.539c0-1.023.823-1.846 1.846-1.846z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 910 B |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<title>Grafana</title>
|
|
||||||
<path d="M23.02 10.59a8.578 8.578 0 0 0-.862-3.034 8.911 8.911 0 0 0-1.789-2.445c.337-1.342-.413-2.505-.413-2.505-1.292-.08-2.113.4-2.416.62-.052-.02-.102-.044-.154-.064-.22-.089-.446-.172-.677-.247-.231-.073-.47-.14-.711-.197a9.867 9.867 0 0 0-.875-.161C14.557.753 12.94 0 12.94 0c-1.804 1.145-2.147 2.744-2.147 2.744l-.018.093c-.098.029-.2.057-.298.088-.138.042-.275.094-.413.143-.138.055-.275.107-.41.166a8.869 8.869 0 0 0-1.557.87l-.063-.029c-2.497-.955-4.716.195-4.716.195-.203 2.658.996 4.33 1.235 4.636a11.608 11.608 0 0 0-.607 2.635C1.636 12.677.953 15.014.953 15.014c1.926 2.214 4.171 2.351 4.171 2.351.003-.002.006-.002.006-.005.285.509.615.994.986 1.446.156.19.32.371.488.548-.704 2.009.099 3.68.099 3.68 2.144.08 3.553-.937 3.849-1.173a9.784 9.784 0 0 0 3.164.501h.08l.055-.003.107-.002.103-.005.003.002c1.01 1.44 2.788 1.646 2.788 1.646 1.264-1.332 1.337-2.653 1.337-2.94v-.058c0-.02-.003-.039-.003-.06.265-.187.52-.387.758-.6a7.875 7.875 0 0 0 1.415-1.7c1.43.083 2.437-.885 2.437-.885-.236-1.49-1.085-2.216-1.264-2.354l-.018-.013-.016-.013a.217.217 0 0 1-.031-.02c.008-.092.016-.18.02-.27.011-.162.016-.323.016-.48v-.253l-.005-.098-.008-.135a1.891 1.891 0 0 0-.01-.13c-.003-.042-.008-.083-.013-.125l-.016-.124-.018-.122a6.215 6.215 0 0 0-2.032-3.73 6.015 6.015 0 0 0-3.222-1.46 6.292 6.292 0 0 0-.85-.048l-.107.002h-.063l-.044.003-.104.008a4.777 4.777 0 0 0-3.335 1.695c-.332.4-.592.84-.768 1.297a4.594 4.594 0 0 0-.312 1.817l.003.091c.005.055.007.11.013.164a3.615 3.615 0 0 0 .698 1.82 3.53 3.53 0 0 0 1.827 1.282c.33.098.66.14.971.137.039 0 .078 0 .114-.002l.063-.003c.02 0 .041-.003.062-.003.034-.002.065-.007.099-.01.007 0 .018-.003.028-.003l.031-.005.06-.008a1.18 1.18 0 0 0 .112-.02c.036-.008.072-.013.109-.024a2.634 2.634 0 0 0 .914-.415c.028-.02.056-.041.085-.065a.248.248 0 0 0 .039-.35.244.244 0 0 0-.309-.06l-.078.042c-.09.044-.184.083-.283.116a2.476 2.476 0 0 1-.475.096c-.028.003-.054.006-.083.006l-.083.002c-.026 0-.054 0-.08-.002l-.102-.006h-.012l-.024.006c-.016-.003-.031-.003-.044-.006-.031-.002-.06-.007-.091-.01a2.59 2.59 0 0 1-.724-.213 2.557 2.557 0 0 1-.667-.438 2.52 2.52 0 0 1-.805-1.475 2.306 2.306 0 0 1-.029-.444l.006-.122v-.023l.002-.031c.003-.021.003-.04.005-.06a3.163 3.163 0 0 1 1.352-2.29 3.12 3.12 0 0 1 .937-.43 2.946 2.946 0 0 1 .776-.101h.06l.07.002.045.003h.026l.07.005a4.041 4.041 0 0 1 1.635.49 3.94 3.94 0 0 1 1.602 1.662 3.77 3.77 0 0 1 .397 1.414l.005.076.003.075c.002.026.002.05.002.075 0 .024.003.052 0 .07v.065l-.002.073-.008.174a6.195 6.195 0 0 1-.08.639 5.1 5.1 0 0 1-.267.927 5.31 5.31 0 0 1-.624 1.13 5.052 5.052 0 0 1-3.237 2.014 4.82 4.82 0 0 1-.649.066l-.039.003h-.287a6.607 6.607 0 0 1-1.716-.265 6.776 6.776 0 0 1-3.4-2.274 6.75 6.75 0 0 1-.746-1.15 6.616 6.616 0 0 1-.714-2.596l-.005-.083-.002-.02v-.056l-.003-.073v-.096l-.003-.104v-.07l.003-.163c.008-.22.026-.45.054-.678a8.707 8.707 0 0 1 .28-1.355c.128-.444.286-.872.473-1.277a7.04 7.04 0 0 1 1.456-2.1 5.925 5.925 0 0 1 .953-.763c.169-.111.343-.213.524-.306.089-.05.182-.091.273-.135.047-.02.093-.042.138-.062a7.177 7.177 0 0 1 .714-.267l.145-.045c.049-.015.098-.026.148-.041.098-.029.197-.052.296-.076.049-.013.1-.02.15-.033l.15-.032.151-.028.076-.013.075-.01.153-.024c.057-.01.114-.013.171-.023l.169-.021c.036-.003.073-.008.106-.01l.073-.008.036-.003.042-.002c.057-.003.114-.008.171-.01l.086-.006h.023l.037-.003.145-.007a7.999 7.999 0 0 1 1.708.125 7.917 7.917 0 0 1 2.048.68 8.253 8.253 0 0 1 1.672 1.09l.09.077.089.078c.06.052.114.107.171.159.057.052.112.106.166.16.052.055.107.107.159.164a8.671 8.671 0 0 1 1.41 1.978c.012.026.028.052.04.078l.04.078.075.156c.023.051.05.1.07.153l.065.15a8.848 8.848 0 0 1 .45 1.34.19.19 0 0 0 .201.142.186.186 0 0 0 .172-.184c.01-.246.002-.532-.024-.856z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 130 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<title>Home Assistant</title>
|
|
||||||
<path fill="white" d="M22.939 10.627 13.061.749a1.505 1.505 0 0 0-2.121 0l-9.879 9.878C.478 11.21 0 12.363 0 13.187v9c0 .826.675 1.5 1.5 1.5h9.227l-4.063-4.062a2.034 2.034 0 0 1-.664.113c-1.13 0-2.05-.92-2.05-2.05s.92-2.05 2.05-2.05 2.05.92 2.05 2.05c0 .233-.041.456-.113.665l3.163 3.163V9.928a2.05 2.05 0 0 1-1.15-1.84c0-1.13.92-2.05 2.05-2.05s2.05.92 2.05 2.05a2.05 2.05 0 0 1-1.15 1.84v8.127l3.146-3.146A2.051 2.051 0 0 1 18 12.239c1.13 0 2.05.92 2.05 2.05s-.92 2.05-2.05 2.05c-.25 0-.488-.047-.709-.13L12.9 20.602v3.088h9.6c.825 0 1.5-.675 1.5-1.5v-9c0-.825-.477-1.977-1.061-2.561z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 702 B |