add homekeeper stack
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code when working in this repository.
|
||||
|
||||
## Platform Overview
|
||||
|
||||
**Homekeeper** — self-hosted platform for a *Selbstversorgerhof* (self-sufficiency farm). Multiple focused Shiny apps behind one reverse proxy, all sharing one PostgreSQL database.
|
||||
|
||||
**Owner:** Nico Friess (nico.friess@googlemail.com). UI language: German. Respond in German when Nico writes in German.
|
||||
|
||||
| App | Route | R package | Purpose |
|
||||
|---|---|---|---|
|
||||
| **Beekeeper** | `/beekeeper/` | `./beekeeper/` | Beehive management (Imkerei) |
|
||||
| **Listkeeper** | `/listkeeper/` | `./listkeeper/` | Shopping lists, to-dos, projects |
|
||||
|
||||
Planned: Garten, Tiere, Haus & Hof.
|
||||
|
||||
All UI must work on mobile (Android). Bootstrap 5, no fixed pixel widths.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser
|
||||
└── nginx (reverse proxy)
|
||||
├── /beekeeper/ → Shiny :3838
|
||||
├── /listkeeper/ → Shiny :3839
|
||||
└── /api/ → FastAPI :8000
|
||||
└── PostgreSQL :5432
|
||||
```
|
||||
|
||||
**Shiny apps do not touch the database directly.** All data access goes through the FastAPI service via `httr2` (see API Client Pattern below). The `db_<entity>.R` files in each R package are thin wrappers around API calls — there is no DBI anywhere in the Shiny code.
|
||||
|
||||
**FastAPI** (`./api/`) handles schema creation (`init_db()` on startup), auth (HTTP Basic), and all SQL. It exposes a versioned REST API at `/v1/`.
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
# Start everything (builds images on first run)
|
||||
docker compose up --build -d
|
||||
|
||||
# Useful
|
||||
docker compose logs -f beekeeper
|
||||
docker compose logs -f listkeeper
|
||||
docker compose logs -f api
|
||||
docker compose up -d --build beekeeper # rebuild one service
|
||||
docker compose up -d --force-recreate nginx # reload nginx config without rebuild
|
||||
docker compose down
|
||||
|
||||
# App logs (JSONL, written to host via volume)
|
||||
tail -f beekeeper/logs/$(date +%Y-%m-%d)_log.jsonl
|
||||
tail -f listkeeper/logs/$(date +%Y-%m-%d)_log.jsonl
|
||||
```
|
||||
|
||||
Local URLs: `http://localhost/beekeeper/`, `http://localhost/listkeeper/`, `http://localhost/api/docs`
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
. ← repo root (will rename to homekeeper later)
|
||||
├── docker-compose.yml
|
||||
├── nginx.conf ← local reverse proxy; routes to Docker service names
|
||||
├── pg_data/ ← PostgreSQL data (bind mount, gitignored)
|
||||
├── www/ ← landing page served at /
|
||||
│ └── index.html
|
||||
├── api/ ← FastAPI service (Python)
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ └── app/
|
||||
│ ├── main.py ← lifespan, routers, root_path
|
||||
│ ├── database.py ← engine, get_session, init_db()
|
||||
│ ├── auth.py ← HTTP Basic auth, seed_users()
|
||||
│ ├── models/
|
||||
│ └── routers/ ← one file per resource
|
||||
├── beekeeper/ ← R package
|
||||
│ ├── Dockerfile
|
||||
│ ├── DESCRIPTION ← version here
|
||||
│ ├── NEWS.md
|
||||
│ ├── logs/ ← JSONL logs (gitignored)
|
||||
│ └── R/
|
||||
├── listkeeper/ ← R package
|
||||
│ ├── Dockerfile
|
||||
│ ├── DESCRIPTION
|
||||
│ ├── NEWS.md
|
||||
│ ├── logs/
|
||||
│ └── R/
|
||||
└── infrastructure/ ← Ansible for Hetzner VM
|
||||
├── site.yml
|
||||
├── inventory/hosts.yml
|
||||
├── group_vars/all.yml
|
||||
└── roles/
|
||||
├── common/ ← apt, ufw, fail2ban
|
||||
├── podman/ ← install podman, auto-update timer
|
||||
├── nginx/ ← system nginx + certbot (Let's Encrypt)
|
||||
├── gitea/ ← Gitea as Podman Quadlet (git + container registry)
|
||||
└── homekeeper/ ← app Quadlets (db, api, beekeeper, listkeeper)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production (Hetzner VM)
|
||||
|
||||
Target: Ubuntu 24.04, Podman + systemd Quadlets, system nginx.
|
||||
|
||||
| Domain | Purpose |
|
||||
|---|---|
|
||||
| `home.friessn.de` | Landing page + all apps |
|
||||
| `git.friessn.de` | Gitea (git hosting + container registry) |
|
||||
|
||||
**Deployment flow:** push code → build images locally → `docker push git.friessn.de/nico/homekeeper-<app>:latest` → `podman auto-update` on VM pulls new image and restarts container (runs every 10s via systemd timer).
|
||||
|
||||
**Run the Ansible playbook:**
|
||||
```bash
|
||||
cd infrastructure
|
||||
ansible-playbook -i inventory/hosts.yml site.yml
|
||||
```
|
||||
|
||||
First-time setup has a Gitea bootstrap step — see `infrastructure/README.md`.
|
||||
|
||||
**Secrets:** all `CHANGE_ME` values in `group_vars/all.yml` must be set before first deploy. Use `ansible-vault encrypt_string` for passwords.
|
||||
|
||||
The local `docker-compose.yml` is for development only. Production uses Quadlet files in `infrastructure/roles/homekeeper/templates/`.
|
||||
|
||||
---
|
||||
|
||||
## Docker / Podman Services
|
||||
|
||||
| Service | Image | Port (internal) | Notes |
|
||||
|---|---|---|---|
|
||||
| `db` | `postgres:17` | 5432 | data in `./pg_data/` |
|
||||
| `api` | built from `./api/` | 8000 | FastAPI, ROOT_PATH=/api |
|
||||
| `beekeeper` | built from `./beekeeper/` | 3838 | Shiny |
|
||||
| `listkeeper` | built from `./listkeeper/` | 3838 (local) / 3839 (prod) | Shiny, PORT env var |
|
||||
| `nginx` | `nginx:alpine` | 80 | local only; system nginx on VM |
|
||||
|
||||
**ENV vars (all services read from docker-compose / Quadlet):**
|
||||
- `API_URL`, `API_USER`, `API_PASS` — Shiny apps
|
||||
- `DB_HOST/PORT/NAME/USER/PASSWORD`, `ROOT_PATH`, `INITIAL_USERS` — API
|
||||
- `LOG_DIR` — Shiny apps (default `/logs`, mounted from `./*/logs/`)
|
||||
- `PORT` — listkeeper only (default 3838)
|
||||
|
||||
---
|
||||
|
||||
## FastAPI (`./api/`)
|
||||
|
||||
Python 3.12, FastAPI + SQLAlchemy (raw SQL via `session.execute(text(...))`). No ORM queries — complex joins written by hand.
|
||||
|
||||
**Auth:** HTTP Basic. Users stored in `auth.users` (bcrypt). Seeded from `INITIAL_USERS` env var (`"user1:pass1,user2:pass2"`) on first startup.
|
||||
|
||||
**Adding an endpoint:** add a file to `app/routers/`, include it in `main.py`. All routes return `dict(r._mapping)` from raw SQL rows.
|
||||
|
||||
**Performance pattern:** avoid N+1 — use subqueries or JOINs to fetch related data in one query. Example: `colonies` list endpoint includes `last_inspection_date` as a subquery and `item_count`/`checked_count` in the lists endpoint.
|
||||
|
||||
---
|
||||
|
||||
## R Package Pattern
|
||||
|
||||
Plain Shiny — no golem, no rhino. Each package exports one function: `run_app()`.
|
||||
|
||||
**File naming:**
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app.R` | `run_app()` — calls `log_init()`, builds `api` list, starts `shinyApp()` |
|
||||
| `ui.R` | `ui()` — `bslib::page(fillable=FALSE)` with sticky header |
|
||||
| `server.R` | `server()` — initializes modules, navigation switch |
|
||||
| `screen_<name>.R` | One module per screen: `screen_<name>_ui()` + `screen_<name>_server()` |
|
||||
| `db_<entity>.R` | API wrapper functions — call `api_get/post/put/patch/delete()` |
|
||||
| `api_client.R` | HTTP helpers: `api_get`, `api_get_one`, `api_post`, `api_put`, `api_patch`, `api_delete` |
|
||||
| `logger.R` | `log_init()`, `log_info()`, `log_debug()`, `log_error()` — writes JSONL |
|
||||
| `utils.R` | `%||%`, constants, badge helpers |
|
||||
| `widgets.R` | Reusable UI components: `stepper_row`, `toggle_row`, `section_card`, etc. |
|
||||
|
||||
**`app.R` pattern:**
|
||||
```r
|
||||
run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) {
|
||||
log_init()
|
||||
api <- list(
|
||||
base_url = Sys.getenv("API_URL", "http://localhost:8000"),
|
||||
user = Sys.getenv("API_USER", "homestead"),
|
||||
pass = Sys.getenv("API_PASS", "homestead")
|
||||
)
|
||||
server_fn <- function(input, output, session) {
|
||||
log_info("session_start", session_id = session$token)
|
||||
session$onSessionEnded(function() log_info("session_end", session_id = session$token))
|
||||
server(input, output, session, db = api)
|
||||
}
|
||||
shiny::runApp(shiny::shinyApp(ui(), server_fn), host = host, port = port, launch.browser = FALSE)
|
||||
}
|
||||
```
|
||||
|
||||
**Module pattern:**
|
||||
```r
|
||||
screen_example_ui <- function(id) {
|
||||
ns <- NS(id)
|
||||
shiny::tagList(...)
|
||||
}
|
||||
screen_example_server <- function(id, db, nav) {
|
||||
moduleServer(id, function(input, output, session) {
|
||||
ns <- session$ns
|
||||
# db = api list (base_url, user, pass) — pass to api_get/post/etc.
|
||||
# nav = reactiveValues(screen=, apiary_id=, colony_id=) in beekeeper
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**API client pattern (`db_<entity>.R`):**
|
||||
```r
|
||||
db_colonies_list <- function(db, apiary_id = NULL, include_closed = FALSE) {
|
||||
api_get(db, "/v1/colonies", list(apiary_id = apiary_id, include_closed = include_closed))
|
||||
}
|
||||
db_colonies_insert <- function(db, name, apiary_id, ...) {
|
||||
result <- api_post(db, "/v1/colonies", list(name = name, apiary_id = apiary_id, ...))
|
||||
result$id
|
||||
}
|
||||
```
|
||||
|
||||
**Navigation (beekeeper):** stack-based via `nav` reactiveValues. `nav$screen` drives a `switch()` in `output$screen_content`. Back navigation via `BACK` named vector. No `page_navbar()`.
|
||||
|
||||
**Beekeeper screens:**
|
||||
`apiaries → colonies → colony → inspection / varroa / harvest / queens / checklists / actions / lineage`
|
||||
Plus: `quick_inspection` (Schnelldurchsicht, all colonies at once) and `bulk_action` (Sammelmaßnahme, one action for many colonies).
|
||||
|
||||
**UI conventions:**
|
||||
- `bslib::page(fillable = FALSE)` — scrollable
|
||||
- Sticky header: `div(class = "bk-header d-flex ...")` with `position: sticky; top: 0`
|
||||
- Tap cards: `div(class = "card tap-card mb-2", onclick = "Shiny.setInputValue(...)")`
|
||||
- Bootstrap 5 only — no fixed pixel widths
|
||||
- `section_card()`, `collapsible_card()`, `stepper_row()`, `toggle_row()` — use widgets from `widgets.R`
|
||||
|
||||
**Inline interaction (avoiding ID collisions in `lapply` loops):**
|
||||
```r
|
||||
# Buttons: onclick + Shiny.setInputValue instead of actionButton
|
||||
shiny::tags$button(
|
||||
onclick = sprintf("Shiny.setInputValue('%s', %d, {priority:'event'})", ns("select"), row$id)
|
||||
)
|
||||
# Text inputs: pass value via JS
|
||||
shiny::tags$input(
|
||||
onkeyup = sprintf(
|
||||
"if(event.key==='Enter'){var v=this.value.trim();if(v){Shiny.setInputValue('%s',{list_id:%d,text:v,ts:Date.now()},{priority:'event'});this.value=''}}",
|
||||
ns("add_item"), row$id
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Logging:**
|
||||
```r
|
||||
log_init() # once at startup — creates today's JSONL file
|
||||
log_info("msg", k = v) # structured fields as named args
|
||||
log_debug("msg", ...)
|
||||
log_error("msg", ...)
|
||||
```
|
||||
Log files: `./beekeeper/logs/YYYY-MM-DD_log.jsonl` (host), `/logs/` inside container.
|
||||
|
||||
**Versioning:** bump `DESCRIPTION` and add an entry to `NEWS.md` when something meaningful ships. Minor bump (0.x.0) for features, patch (0.0.x) for fixes.
|
||||
|
||||
---
|
||||
|
||||
## Database Schemas
|
||||
|
||||
### `public` — Beekeeper
|
||||
|
||||
**`apiaries`**: `id, name, location_text, lat, lng, notes, created_at`
|
||||
|
||||
**`colonies`**: `id, apiary_id, name, status, parent_colony_id, current_queen_id, created_at, closed_at`
|
||||
- `status`: `wirtschaftsvolk | ableger | jungvolk | schwarmstimmung | eingegangen`
|
||||
- `closed_at IS NULL` = active
|
||||
|
||||
**`queens`**: `id, colony_id, year, breed, origin_colony_id, marked_color, notes, introduced_at, superseded_at`
|
||||
|
||||
**`inspections`**: `id, colony_id, date, time, inspector, brood_frames, honey_frames, food_stores_kg, colony_strength, queen_seen, eggs_seen, queen_cells_count, notes`
|
||||
|
||||
**`inspection_traits`**: `inspection_id, trait, score` — trait: `sanftmut|schwarmtrieb|wabensitz|honigertrag|varroa_hygiene` (1–4)
|
||||
|
||||
**`varroa_counts`**: `id, colony_id, date, time, date_from, method, count, notes` — method: `natuerlicher_totenfall|alkoholwaesche|puderzucker`
|
||||
|
||||
**`varroa_treatments`**: `id, colony_id, date, time, product, method, dosage, notes`
|
||||
|
||||
**`varroa_controls`**: `id, treatment_id, colony_id, date, result, notes`
|
||||
|
||||
**`honey_harvests`**: `id, colony_id, apiary_id, date, time, weight_kg, type, notes`
|
||||
|
||||
**`colony_actions`**: `id, colony_id, date, time, type, notes, related_colony_id, created_at`
|
||||
- `type` is free text. Used values: `ablegerbildung | zwischenbodenableger | brutentnahme | standwechsel | futterung | vereinigung | mittelwand | sonstig`
|
||||
|
||||
**`checklists` / `checklist_items` / `checklist_completions`**
|
||||
|
||||
Colony log: API endpoint `GET /v1/colonies/{id}/log` — UNION ALL across all event tables, returns `date, type, summary, ref_id, created_at`.
|
||||
|
||||
### `lk` — Listkeeper
|
||||
|
||||
**`lk.lists`**: `id, name, type, t_shirt_size, status, notes, created_at, closed_at`
|
||||
- `type`: `shopping | todo | project`
|
||||
- `t_shirt_size`: `XS|S|M|L|XL` (projects only)
|
||||
- `closed_at IS NULL` = active
|
||||
|
||||
**`lk.list_items`**: `id, list_id, text, checked, position, created_at`
|
||||
|
||||
List endpoint returns `item_count` and `checked_count` as aggregated columns (not from a separate query).
|
||||
|
||||
### `auth` — API users
|
||||
|
||||
**`auth.users`**: `id, username, hashed_password, is_active, created_at` — bcrypt, seeded from `INITIAL_USERS` env var.
|
||||
Reference in New Issue
Block a user