Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: cleanup, recipe styling improvements and remove grind size

authored by

Patrick Dewey and committed by tangled.org a6eba791 3e1db004

+12 -1838
-93
BACKLOG.md
··· 1 - ## Description 2 - 3 - This file includes the backlog of features and fixes that need to be done. 4 - Each should be addressed one at a time, and the item should be removed after implementation has been finished and verified. 5 - 6 - --- 7 - 8 - ## Features 9 - 10 - 1. LARGE: complete record styling refactor that changes from table-style to more mobile-friendly style 11 - - Likely a more "post-style" version that is closer to bsky posts 12 - - To be done later down the line 13 - - setting to use legacy table view 14 - 15 - 2. Settings menu (mostly tbd) 16 - - Private mode -- don't show in community feed (records are still public via pds api though) 17 - - Dev mode -- show did, copy did in profiles (remove "logged in as <did>" from home page) 18 - - Toggle for table view vs future post-style view 19 - 20 - - Maybe add loading bars to page loads? (above the header perhaps?) 21 - - A separate nicer pretty loading bar would also be nice on the brews page? 22 - 23 - ## Far Future Considerations 24 - 25 - - Maybe swap from boltdb to sqlite 26 - - Use the non-cgo library? 27 - - Is there a compelling reason to do this? 28 - - Might be good as a sort of witness-cache type thing (record refs to avoid hitting PDS's as often?) 29 - - Probably not worth unless we keep a copy of all (or all recent) network data 30 - 31 - - The profile, manage, and brews list pages all function in a similar fashion, 32 - should one or more of them be consolidated? 33 - - Manage + brews list together probably makes sense 34 - 35 - - IMPORTANT: If this platform gains any traction, we will need some form of content moderation 36 - - Due to the nature of arabica, this will only really need to be text based (text and hyperlinks) 37 - - Malicious link scanning may be reasonable, not sure about deeper text analysis 38 - - Need to do more research into security 39 - - Need admin tooling at the app level that will allow deleting records (may not be possible), 40 - removing from appview, blacklisting users (and maybe IPs?), possibly more 41 - - Having accounts with admin rights may be an approach to this (configured with flags at startup time?) 42 - @arabica.social, @pdewey.com, maybe others? (need trusted users in other time zones probably) 43 - - Add on some piece to the TOS that mentions I reserve the right to de-list content from the platform 44 - - Continue limiting firehose posts to users who have been previously authenticated (keep a permanent record of "trusted" users) 45 - - By logging in users agree to TOS -- can create records to be displayed on the appview ("signal" records) 46 - Attestation signature from appview (or pds -- use key from pds) was source of record being created 47 - - This is a pretty important consideration going forward, lots to consider 48 - 49 - ## Fixes 50 - 51 - - Loading on htmx could probably be snappier by using a loading bar, and waiting until everything is loaded 52 - - Alternative could be using transitionary animations between skeleton and full loads 53 - - Do we even need skeleton loading with SSR? (I think yes here because of PDS data fetches -- maybe not if we kept a copy of the data) 54 - 55 - - Headers in skeletons need to exactly match headers in final table 56 - - Refreshing profile should show either full skeleton with headers, or use the correct headers for the current tab 57 - (It currently shows the brew header for all tabs) 58 - 59 - - Add styling to mail link and back to home button on terms page 60 - 61 - - Take revision pass on text in about and terms 62 - 63 - - Need to cache profile pictures to a database to avoid reloading them frequently 64 - - This may already be done to some extent 65 - 66 - - Tables flash a bit on load, could more smoothly load in? (maybe delay page until the skeleton is rendered at least?) 67 - - I think the profile page does this relatively well, with the profile banner and stats 68 - 69 - ## Refactor 70 - 71 - - Need to think about if it is worth having manage, profile, and brew list as separate pages. 72 - - Manage and profile could probably be merged? (or brew list and manage) 73 - 74 - - Profile tables should all either have edit/delete buttons or none should 75 - - Also, add buttons below each table for that record type would probably be nice 76 - 77 - - Maybe having a way of nesting modals, so a roaster can be created from within the bean modal? 78 - - Maybe have a transition that moves the bean modal to the left, and opens a roaster modal to the right 79 - 80 - ## Notes 81 - 82 - - Popup menu for feed card extras should be centered on the button 83 - - Maybe use a different background color (maybe the button color?) 84 - 85 - - Add a copy AT URI to extras popup 86 - 87 - - Firehose maybe not backfilling likes 88 - 89 - - TODO: add OpenGraph embeds (mainly for brews; beans and roasters can come later) 90 - 91 - - Fix opengraph to show handle, record type and date? 92 - - Then show brewer and bean? 93 - - Add an image of some kind as well
-11
deploy/.env.example
··· 1 - # Arabica Production Configuration 2 - # Copy this file to .env and update with your values 3 - 4 - # Your domain name (required for production) 5 - DOMAIN=arabica.example.com 6 - ACME_EMAIL=admin@example.com 7 - 8 - LOG_LEVEL=info 9 - LOG_FORMAT=json 10 - SERVER_PUBLIC_URL=https://${DOMAIN} 11 - SECURE_COOKIES=true
-31
deploy/Caddyfile
··· 1 - {$DOMAIN:localhost} { 2 - reverse_proxy arabica:18910 { 3 - header_up X-Real-IP {remote_host} 4 - header_up X-Forwarded-For {remote_host} 5 - header_up X-Forwarded-Proto {scheme} 6 - header_up X-Forwarded-Host {host} 7 - } 8 - 9 - header { 10 - X-Content-Type-Options "nosniff" 11 - X-Frame-Options "SAMEORIGIN" 12 - Referrer-Policy "strict-origin-when-cross-origin" 13 - -Server 14 - } 15 - 16 - log { 17 - output stdout 18 - format json 19 - level INFO 20 - } 21 - 22 - encode gzip 23 - 24 - @static { 25 - path /static/* 26 - } 27 - handle @static { 28 - header Cache-Control "public, max-age=31536000, immutable" 29 - reverse_proxy arabica:18910 30 - } 31 - }
-51
deploy/Dockerfile
··· 1 - # Build stage 2 - FROM golang:1.25-alpine AS builder 3 - 4 - WORKDIR /app 5 - 6 - # Install build dependencies 7 - RUN apk add --no-cache git 8 - 9 - # Copy go mod files first for caching 10 - COPY go.mod go.sum ./ 11 - RUN go mod download 12 - 13 - # Copy source code 14 - COPY . . 15 - 16 - # Build the binary 17 - RUN CGO_ENABLED=0 GOOS=linux go build -o arabica cmd/server/main.go 18 - 19 - # Runtime stage 20 - FROM alpine:3.23 21 - 22 - WORKDIR /app 23 - 24 - # Install ca-certificates for HTTPS calls 25 - RUN apk add --no-cache ca-certificates 26 - 27 - # Create non-root user 28 - RUN adduser -D -u 1000 arabica 29 - 30 - # Create data directory 31 - RUN mkdir -p /data && chown arabica:arabica /data 32 - 33 - # Copy binary from builder 34 - COPY --from=builder /app/arabica . 35 - 36 - # Copy static assets 37 - COPY --chown=arabica:arabica static/ ./static/ 38 - 39 - # Note: Templ components are compiled into the binary, no need to copy .templ files 40 - 41 - # Switch to non-root user 42 - USER arabica 43 - 44 - # Environment defaults 45 - ENV PORT=18910 46 - ENV ARABICA_DB_PATH=/data/arabica.db 47 - ENV LOG_FORMAT=json 48 - 49 - EXPOSE 18910 50 - 51 - CMD ["./arabica"]
-37
deploy/compose.yml
··· 1 - services: 2 - caddy: 3 - image: caddy:2-alpine 4 - ports: 5 - - "80:80" 6 - - "443:443" 7 - - "443:443/udp" 8 - volumes: 9 - - ./Caddyfile:/etc/caddy/Caddyfile:ro 10 - - caddy-data:/data 11 - - caddy-config:/config 12 - environment: 13 - - DOMAIN=${DOMAIN:-localhost} 14 - - ACME_EMAIL=${ACME_EMAIL:-} 15 - restart: unless-stopped 16 - depends_on: 17 - - arabica 18 - 19 - arabica: 20 - build: . 21 - expose: 22 - - "18910" 23 - volumes: 24 - - arabica-data:/data 25 - environment: 26 - - PORT=18910 27 - - ARABICA_DB_PATH=/data/arabica.db 28 - - LOG_LEVEL=${LOG_LEVEL:-info} 29 - - LOG_FORMAT=${LOG_FORMAT:-json} 30 - - SERVER_PUBLIC_URL=${SERVER_PUBLIC_URL:-https://${DOMAIN}} 31 - - SECURE_COOKIES=true 32 - restart: unless-stopped 33 - 34 - volumes: 35 - arabica-data: 36 - caddy-data: 37 - caddy-config:
-138
docs/deploy.md
··· 1 - # Arabica Deployment Guide 2 - 3 - Quick guide to deploy Arabica to a VPS with Docker and automatic HTTPS. 4 - 5 - ## Prerequisites 6 - 7 - - VPS with Docker and Docker Compose installed 8 - - Domain name pointing to your VPS IP address (A record) 9 - - Ports 80 and 443 open in firewall 10 - 11 - ## Quick Start (Production) 12 - 13 - 1. **Clone the repository:** 14 - ```bash 15 - git clone <repository-url> 16 - cd arabica 17 - ``` 18 - 19 - 2. **Configure your domain:** 20 - ```bash 21 - cp .env.example .env 22 - nano .env 23 - ``` 24 - 25 - Update `.env` with your domain: 26 - ```env 27 - DOMAIN=arabica.yourdomain.com 28 - ACME_EMAIL=your-email@example.com 29 - ``` 30 - 31 - 3. **Deploy:** 32 - ```bash 33 - docker compose up -d 34 - ``` 35 - 36 - That's it! Caddy will automatically: 37 - - Obtain SSL certificates from Let's Encrypt 38 - - Renew certificates before expiry 39 - - Redirect HTTP to HTTPS 40 - - Proxy requests to Arabica 41 - 42 - 4. **Check logs:** 43 - ```bash 44 - docker compose logs -f 45 - ``` 46 - 47 - 5. **Visit your site:** 48 - ``` 49 - https://arabica.yourdomain.com 50 - ``` 51 - 52 - ## Local Development 53 - 54 - To run locally without a domain: 55 - 56 - ```bash 57 - docker compose up 58 - ``` 59 - 60 - Then visit `http://localhost` (Caddy will serve on port 80). 61 - 62 - ## Updating 63 - 64 - ```bash 65 - git pull 66 - docker compose down 67 - docker compose build 68 - docker compose up -d 69 - ``` 70 - 71 - ## Troubleshooting 72 - 73 - ### Certificate Issues 74 - 75 - If Let's Encrypt can't issue a certificate: 76 - - Ensure your domain DNS is pointing to your VPS 77 - - Check ports 80 and 443 are accessible 78 - - Check logs: `docker compose logs caddy` 79 - 80 - ### View Arabica logs 81 - 82 - ```bash 83 - docker compose logs -f arabica 84 - ``` 85 - 86 - ### Reset everything 87 - 88 - ```bash 89 - docker compose down -v # Warning: deletes all data 90 - docker compose up -d 91 - ``` 92 - 93 - ## Production Checklist 94 - 95 - - [ ] Domain DNS pointing to VPS 96 - - [ ] Ports 80 and 443 open in firewall 97 - - [ ] `.env` file configured with your domain 98 - - [ ] Valid email set for Let's Encrypt notifications 99 - - [ ] Regular backups of `arabica-data` volume 100 - 101 - ## Backup 102 - 103 - To backup user data: 104 - 105 - ```bash 106 - docker compose exec arabica cp /data/arabica.db /data/arabica-backup.db 107 - docker cp $(docker compose ps -q arabica):/data/arabica-backup.db ./backup-$(date +%Y%m%d).db 108 - ``` 109 - 110 - ## Advanced Configuration 111 - 112 - ### Custom Caddyfile 113 - 114 - Edit `Caddyfile` directly for advanced options like: 115 - - Rate limiting 116 - - Custom headers 117 - - IP whitelisting 118 - - Multiple domains 119 - 120 - ### Environment Variables 121 - 122 - All available environment variables in `.env`: 123 - 124 - | Variable | Default | Description | 125 - | ------------------- | ------------------------------------ | ------------------------------- | 126 - | `DOMAIN` | localhost | Your domain name | 127 - | `ACME_EMAIL` | (empty) | Email for Let's Encrypt | 128 - | `LOG_LEVEL` | info | debug/info/warn/error | 129 - | `LOG_FORMAT` | json | console/json | 130 - | `SERVER_PUBLIC_URL` | https://${DOMAIN} | Override public URL | 131 - | `SECURE_COOKIES` | true | Use secure cookies | 132 - 133 - ## Support 134 - 135 - For issues, check the logs first: 136 - ```bash 137 - docker compose logs 138 - ```
-1
docs/ideas.md
··· 15 15 | `temperature` | int (tenths °C) | Same encoding as brew | 16 16 | `coffeeAmount` | int (grams) | Dose | 17 17 | `waterAmount` | int (grams) | Total water | 18 - | `grindSize` | string | "medium-fine" | 19 18 | `timeSeconds` | int | Total brew time | 20 19 | `pours` | array | `[{waterAmount, timeSeconds}]` — pour schedule | 21 20 | `tags` | []string | Optional: ["fruity", "light roast", "comp-prep"] |
+2 -6
docs/recipes.md
··· 30 30 ### Level 0: What We Have Now 31 31 32 32 ``` 33 - name, brewerRef, coffeeAmount, waterAmount, grindSize, pours[], notes 33 + name, brewerRef, coffeeAmount, waterAmount, pours[], notes 34 34 ``` 35 35 36 36 Fixed schema. Works for pourover. Breaks for everything else. Pours are the ··· 50 50 **Optional structured fields (queryable when present):** 51 51 - `waterAmount` — total water (most methods, but not all — e.g., espresso 52 52 yield is measured differently) 53 - - `grindSize` — text or numeric 54 53 - `brewerRef` / `brewerType` — gear reference 55 54 56 55 **Extensions (open union array):** ··· 243 242 name: "Daily V60" 244 243 coffeeAmount: 180 (18.0g) 245 244 waterAmount: 3000 (300.0g) [if kept in core] 246 - grindSize: "Medium-Fine" 247 245 brewerRef: at://did/...brewer/abc 248 246 notes: "Standard recipe, nothing fancy" 249 247 ``` ··· 258 256 name: "Hoffmann V60" 259 257 coffeeAmount: 150 (15.0g) 260 258 waterAmount: 2500 (250.0g) 261 - grindSize: "Medium" 262 259 brewerRef: at://did/...brewer/abc 263 260 264 261 parameters: ··· 355 352 356 353 The current lexicon can evolve to Level 2 without breaking existing records: 357 354 358 - 1. Existing fields (`coffeeAmount`, `waterAmount`, `grindSize`, `brewerRef`, 355 + 1. Existing fields (`coffeeAmount`, `waterAmount`, `brewerRef`, 359 356 `brewerType`, `pours`) remain and continue to work 360 357 2. Add `parameters` and `steps` as new optional array fields 361 358 3. Existing `pours` could be deprecated in favor of `steps` with `#pourStep`, ··· 382 379 "name": { "type": "string", "maxLength": 200 }, 383 380 "coffeeAmount": { "type": "integer", "minimum": 0 }, 384 381 "waterAmount": { "type": "integer", "minimum": 0 }, 385 - "grindSize": { "type": "string", "maxLength": 100 }, 386 382 "brewerRef": { "type": "string", "format": "at-uri" }, 387 383 "brewerType": { "type": "string", "maxLength": 100 }, 388 384 "parameters": {
-1
docs/recipes.norg
··· 27 27 - `brewerType` (string) 28 28 - `coffeeAmount` (int -- should be * 10 but isn't) 29 29 - `waterAmount` (int * 10) 30 - - `grindSize` (int * 10) 31 30 - `pours` (array of `#pour`) (references {*** Pour Schema})` 32 31 - `notes`: (string) reeform description field 33 32
-373
docs/witness-cache-plan.md
··· 1 - # Witness Cache Implementation Plan 2 - 3 - **Goal:** Eliminate redundant PDS requests by using the firehose SQLite index as 4 - a local read cache for Arabica records. 5 - 6 - **Date:** 2026-03-22 7 - 8 - --- 9 - 10 - ## Problem 11 - 12 - Several pages make excessive PDS XRPC calls per page load: 13 - 14 - | Page | PDS Calls | Breakdown | 15 - | --------- | --------- | ----------------------------------------------------------------------------- | 16 - | Brew View | 5-7 | 1 brew + 4-5 sequential ref resolves (bean, roaster, grinder, brewer, recipe) | 17 - | Profile | 6-7 | 5 listRecords (all collections) + handle resolve + profile fetch | 18 - | Manage | 5+ | listRecords for all entity types | 19 - | Brew Form | 5+ | All entities for select dropdowns | 20 - 21 - The in-memory `SessionCache` (2min TTL, per-session) helps on rapid 22 - re-navigation but is volatile — server restarts and new sessions trigger full 23 - PDS re-fetches. 24 - 25 - ## Architecture 26 - 27 - Three-tier read path: 28 - 29 - ``` 30 - Request → L1: SessionCache (in-memory, 2min TTL, parsed Go structs) 31 - ↓ miss 32 - L2: WitnessCache (firehose SQLite, updated in real-time via relay) 33 - ↓ miss 34 - L3: PDS (remote XRPC calls) 35 - ``` 36 - 37 - The firehose index already stores every Arabica record it sees from the relay. 38 - We expose it as a read-only `WitnessCache` interface and inject it into 39 - `AtprotoStore`. 40 - 41 - Writes always go to PDS. The firehose keeps the witness cache consistent 42 - asynchronously (typically <1s propagation delay). 43 - 44 - ## Design Decisions 45 - 46 - ### Use firehose index directly, not a separate SQLite cache 47 - 48 - The firehose index already has all the data. A dedicated cache would duplicate 49 - storage and require its own invalidation logic. The firehose's relay 50 - subscription handles consistency for free. 51 - 52 - ### Resolve references from witness cache too 53 - 54 - The WIP branch's biggest flaw: after fetching a brew from the witness cache, it 55 - still called `ResolveBrewRefs` which made 4-5 individual PDS calls for the 56 - referenced bean/grinder/brewer/recipe. All of those records are also in the 57 - firehose index. 58 - 59 - A single brew view should go from 5-7 PDS calls to 0 when the witness cache is 60 - warm. 61 - 62 - ### Keep SessionCache as L1 63 - 64 - SessionCache holds parsed Go structs — zero deserialization cost on hit. The 65 - witness cache (L2) requires JSON → struct conversion but avoids network. Both 66 - layers serve different performance profiles. 67 - 68 - ### Don't cache profiles/handles in witness cache 69 - 70 - Profiles come from the AppView/PLC directory, not Arabica lexicons. The existing 71 - `ARABICA_PROFILE_CACHE_TTL` (1h) handles those. The witness cache only covers 72 - Arabica record collections. 73 - 74 - ### Fall through to PDS after writes 75 - 76 - When a user creates/updates/deletes, the SessionCache is invalidated (already 77 - happens today). The next read falls through to PDS since the firehose may not 78 - have propagated the change yet. This preserves read-your-writes consistency 79 - without any new invalidation logic. 80 - 81 - --- 82 - 83 - ## Tasks 84 - 85 - ### Task 1: WitnessCache interface and FeedIndex implementation 86 - 87 - **Files:** 88 - 89 - - Create: `internal/atproto/witness.go` 90 - - Modify: `internal/firehose/index.go` 91 - 92 - Define the interface in `atproto` (avoids import cycle with `firehose`): 93 - 94 - ```go 95 - // internal/atproto/witness.go 96 - package atproto 97 - 98 - type WitnessRecord struct { 99 - URI string 100 - DID string 101 - Collection string 102 - RKey string 103 - CID string 104 - Record json.RawMessage 105 - IndexedAt time.Time 106 - CreatedAt time.Time 107 - } 108 - 109 - type WitnessCache interface { 110 - // Returns (nil, nil) when not found. 111 - GetWitnessRecord(ctx context.Context, uri string) (*WitnessRecord, error) 112 - // Returns empty slice when none found. 113 - ListWitnessRecords(ctx context.Context, did, collection string) ([]*WitnessRecord, error) 114 - } 115 - ``` 116 - 117 - Add composite index to firehose schema for efficient DID+collection queries: 118 - 119 - ```sql 120 - CREATE INDEX IF NOT EXISTS idx_records_did_coll ON records(did, collection, created_at DESC); 121 - ``` 122 - 123 - Implement `GetWitnessRecord` and `ListWitnessRecords` on `FeedIndex`, with 124 - compile-time interface check: 125 - 126 - ```go 127 - var _ atproto.WitnessCache = (*FeedIndex)(nil) 128 - ``` 129 - 130 - --- 131 - 132 - ### Task 2: Witness cache helpers in AtprotoStore 133 - 134 - **Files:** 135 - 136 - - Modify: `internal/atproto/store.go` 137 - 138 - Add `witnessCache WitnessCache` field to `AtprotoStore`. Add 139 - `NewAtprotoStoreWithWitness` constructor. 140 - 141 - Add two private helpers: 142 - 143 - ```go 144 - // getFromWitness fetches a single record by collection+rkey. 145 - // Returns nil when cache is not configured or record not found. 146 - func (s *AtprotoStore) getFromWitness(ctx context.Context, collection, rkey string) *WitnessRecord 147 - 148 - // listFromWitness returns all cached records for a collection. 149 - // Returns nil when cache is not configured or empty. 150 - func (s *AtprotoStore) listFromWitness(ctx context.Context, collection string) []*WitnessRecord 151 - ``` 152 - 153 - Add `witnessRecordToMap` for converting `json.RawMessage` into the 154 - `map[string]interface{}` format the existing `Record*` conversion functions 155 - expect. 156 - 157 - --- 158 - 159 - ### Task 3: Cache-first List operations 160 - 161 - **Files:** 162 - 163 - - Modify: `internal/atproto/store.go` 164 - 165 - For each `List*` method (ListBeans, ListRoasters, ListGrinders, ListBrewers, 166 - ListRecipes, ListBrews): 167 - 168 - 1. Check SessionCache (unchanged) 169 - 2. Try `listFromWitness` — if records found, convert and populate SessionCache 170 - 3. Fall through to PDS on miss 171 - 172 - The read path becomes: 173 - 174 - ```go 175 - func (s *AtprotoStore) ListBeans(ctx context.Context) ([]*models.Bean, error) { 176 - // L1: session cache 177 - if cached := s.cache.Get(s.sessionID); cached != nil && cached.Beans != nil && cached.IsValid() { 178 - return cached.Beans, nil 179 - } 180 - // L2: witness cache 181 - if wRecords := s.listFromWitness(ctx, NSIDBean); wRecords != nil { 182 - beans := convertWitnessRecordsToBeans(wRecords) 183 - s.cache.SetBeans(s.sessionID, beans) 184 - return beans, nil 185 - } 186 - // L3: PDS (existing code, unchanged) 187 - ... 188 - } 189 - ``` 190 - 191 - --- 192 - 193 - ### Task 4: Cache-first Get operations with reference resolution from witness 194 - 195 - **Files:** 196 - 197 - - Modify: `internal/atproto/store.go` 198 - 199 - This is the highest-impact change. For `GetBrewByRKey`: 200 - 201 - 1. Get brew from witness cache 202 - 2. Get each reference (bean, grinder, brewer, recipe) from witness cache too 203 - 3. Only fall back to PDS if any witness lookup misses 204 - 205 - ```go 206 - func (s *AtprotoStore) GetBrewByRKey(ctx context.Context, rkey string) (*models.Brew, error) { 207 - if wr := s.getFromWitness(ctx, NSIDBrew, rkey); wr != nil { 208 - brew := convertWitnessRecordToBrew(wr) 209 - 210 - // Resolve refs from witness cache — NOT from PDS 211 - if beanURI != "" { 212 - if beanWR, _ := s.witnessCache.GetWitnessRecord(ctx, beanURI); beanWR != nil { 213 - brew.Bean = convertWitnessRecordToBean(beanWR) 214 - // Resolve roaster ref from witness too 215 - if roasterURI != "" { 216 - if roasterWR, _ := s.witnessCache.GetWitnessRecord(ctx, roasterURI); roasterWR != nil { 217 - brew.Bean.Roaster = convertWitnessRecordToRoaster(roasterWR) 218 - } 219 - } 220 - } 221 - } 222 - // Same for grinder, brewer, recipe... 223 - 224 - return brew, nil 225 - } 226 - 227 - // PDS fallback (existing code) 228 - ... 229 - } 230 - ``` 231 - 232 - **Impact:** Brew view page goes from 5-7 PDS calls to 0. 233 - 234 - Apply the same witness-first reference resolution to `GetBeanByRKey` (resolves 235 - roaster ref) and any other `Get*ByRKey` that resolves references. 236 - 237 - --- 238 - 239 - ### Task 5: Witness cache for public/profile reads 240 - 241 - **Files:** 242 - 243 - - Modify: `internal/handlers/profile.go` 244 - - Modify: `internal/handlers/brew.go` (public brew view path) 245 - - Modify: `internal/handlers/entity_views.go` (public entity view paths) 246 - 247 - The profile page and shared view pages use `PublicClient` which bypasses both 248 - SessionCache and WitnessCache entirely. The firehose index has these records for 249 - all known users. 250 - 251 - Give handlers access to the witness cache for public reads: 252 - 253 - ```go 254 - func (h *Handler) HandleProfilePage(w http.ResponseWriter, r *http.Request) { 255 - // Try witness cache for the target user's records 256 - if beans := h.listFromWitnessPublic(ctx, targetDID, NSIDBean); beans != nil { 257 - // Use cached data, skip PDS calls 258 - } 259 - } 260 - ``` 261 - 262 - Or create a `PublicWitnessStore` wrapper: 263 - 264 - ```go 265 - type PublicWitnessStore struct { 266 - witness WitnessCache 267 - publicClient *PublicClient // fallback 268 - } 269 - ``` 270 - 271 - This could be a follow-up if scoping is a concern — the authenticated path 272 - (Tasks 3-4) covers the most common case. 273 - 274 - --- 275 - 276 - ### Task 6: Metrics 277 - 278 - **Files:** 279 - 280 - - Modify: `internal/metrics/metrics.go` 281 - 282 - Add Prometheus counters: 283 - 284 - ```go 285 - var ( 286 - WitnessCacheHitsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 287 - Name: "arabica_witness_cache_hits_total", 288 - Help: "Total witness cache hits (PDS request avoided)", 289 - }, []string{"collection"}) 290 - 291 - WitnessCacheMissesTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 292 - Name: "arabica_witness_cache_misses_total", 293 - Help: "Total witness cache misses (fell back to PDS)", 294 - }, []string{"collection"}) 295 - ) 296 - ``` 297 - 298 - Instrument every witness cache check point in store.go. This is critical for 299 - validating hit rates and identifying remaining cold-start gaps. 300 - 301 - --- 302 - 303 - ### Task 7: Wire into main.go and handlers 304 - 305 - **Files:** 306 - 307 - - Modify: `internal/handlers/handlers.go` 308 - - Modify: `cmd/server/main.go` 309 - 310 - Add `witnessCache atproto.WitnessCache` field to `Handler`. Add 311 - `SetWitnessCache` method. 312 - 313 - In `getAtprotoStore`, use `NewAtprotoStoreWithWitness` when witness cache is 314 - configured: 315 - 316 - ```go 317 - func (h *Handler) getAtprotoStore(r *http.Request) (database.Store, bool) { 318 - ... 319 - if h.witnessCache != nil { 320 - return atproto.NewAtprotoStoreWithWitness(client, did, sessionID, h.sessionCache, h.witnessCache), true 321 - } 322 - return atproto.NewAtprotoStore(client, did, sessionID, h.sessionCache), true 323 - } 324 - ``` 325 - 326 - In main.go, wire `feedIndex` as the witness cache after it's initialized. 327 - 328 - --- 329 - 330 - ### Task 8: Public feed cache invalidation 331 - 332 - **Files:** 333 - 334 - - Modify: `internal/feed/service.go` 335 - - Modify: `internal/handlers/brew.go`, `internal/handlers/entities.go` 336 - 337 - Add `InvalidatePublicFeedCache()` method to `feed.Service`. Call it from 338 - create/update/delete handlers so unauthenticated feed views reflect changes 339 - immediately. 340 - 341 - This was included in the WIP and is a good standalone improvement regardless of 342 - the witness cache. 343 - 344 - --- 345 - 346 - ## Non-Goals 347 - 348 - - **Profile/handle caching** — handled by existing `ARABICA_PROFILE_CACHE_TTL` 349 - - **CID-based staleness detection** — future optimization, not needed for v1 350 - - **Separate SQLite cache database** — firehose index already has the data 351 - - **Increasing SessionCache TTL** — 2 minutes is correct for multi-device sync 352 - 353 - ## Risks and Mitigations 354 - 355 - | Risk | Mitigation | 356 - | ------------------------------------------------------ | ------------------------------------------------------------------------- | 357 - | Firehose lag causes stale reads | PDS fallback on SessionCache invalidation after writes | 358 - | New user before firehose backfill | Witness miss → PDS fallback (transparent) | 359 - | Firehose down | Witness returns empty → PDS fallback (existing behavior) | 360 - | JSON parsing differences between witness and PDS paths | Same `Record*` conversion functions, same `map[string]interface{}` format | 361 - | Schema changes to firehose records table | WitnessCache interface isolates AtprotoStore from schema details | 362 - 363 - ## Expected Impact 364 - 365 - | Page | Before | After (warm cache) | 366 - | --------- | ------------- | ------------------ | 367 - | Brew View | 5-7 PDS calls | 0 | 368 - | Profile | 6-7 PDS calls | 0 (with Task 5) | 369 - | Manage | 5+ PDS calls | 0 | 370 - | Brew Form | 5+ PDS calls | 0 | 371 - 372 - Server restarts no longer cause a thundering herd of PDS requests — the witness 373 - cache survives restarts since it's backed by SQLite on disk.
-50
grafana/README.md
··· 1 - # Grafana Dashboards 2 - 3 - Importable Grafana dashboard definitions for monitoring Arabica. 4 - 5 - ## Dashboards 6 - 7 - ### `arabica-logs.json` - Log-Based Metrics 8 - 9 - Queries structured JSON logs via **Loki**. No code changes needed - works with existing zerolog output. 10 - 11 - **Prerequisite:** Ship Arabica logs to Loki (e.g., via Promtail, Alloy, or Docker log driver). Logs must be in JSON format (`LOG_FORMAT=json`). 12 - 13 - **Log selector:** The dashboard uses a template variable (`$log_selector`) with three presets: 14 - 15 - - `unit="arabica.service"` (default) - NixOS/systemd journal via Promtail 16 - - `syslog_identifier="arabica"` - journald syslog identifier 17 - - `app="arabica"` - Docker log driver or custom labels 18 - 19 - Select the matching option from the dropdown at the top of the dashboard, or type a custom value. 20 - 21 - **Sections:** 22 - 23 - - **Overview** - stat panels for total requests, errors, logins, reports, join requests 24 - - **HTTP Traffic** - requests by status/method, top paths, response latency 25 - - **Firehose** - events by collection/operation, errors, backfill activity 26 - - **Authentication & Users** - login success/failure, join requests, invites 27 - - **Moderation** - reports, hide/unhide/block actions, permission denials 28 - - **PDS & ATProto** - PDS request volume/latency/errors by method and collection 29 - - **Errors & Warnings** - error/warn timeline + recent error log viewer 30 - 31 - ### `arabica-prometheus.json` - Prometheus Metrics 32 - 33 - Queries instrumented Prometheus counters, histograms, and gauges exposed at `/metrics`. 34 - 35 - **Prerequisite:** Arabica exposes a `/metrics` endpoint (Prometheus format). Configure Prometheus to scrape it. 36 - 37 - **Sections:** 38 - 39 - - **Overview** - request rate, error rate, p95 latency, firehose connection, events/s, cache hit rate 40 - - **HTTP Traffic** - request rate by status/path, latency percentiles (p50/p95/p99), latency by path 41 - - **Firehose** - events by collection/operation, error rate, connection state 42 - - **PDS / ATProto** - PDS request rate by method/collection, latency by method, error rate 43 - - **Feed Cache** - cache hits vs misses, hit rate over time 44 - 45 - ### Importing 46 - 47 - 1. In Grafana, go to **Dashboards > Import** 48 - 2. Upload the JSON file or paste its contents 49 - 3. Select your data source (Loki or Prometheus) when prompted 50 - 4. For the Loki dashboard, select the correct log selector from the dropdown (defaults to `unit="arabica.service"` for NixOS systemd)
-976
grafana/arabica-logs.json
··· 1 - { 2 - "annotations": { 3 - "list": [ 4 - { 5 - "builtIn": 1, 6 - "datasource": { "type": "grafana", "uid": "-- Grafana --" }, 7 - "enable": true, 8 - "hide": true, 9 - "iconColor": "rgba(0, 211, 255, 1)", 10 - "name": "Annotations & Alerts", 11 - "type": "dashboard" 12 - } 13 - ] 14 - }, 15 - "editable": true, 16 - "fiscalYearStartMonth": 0, 17 - "graphTooltip": 1, 18 - "id": null, 19 - "links": [], 20 - "panels": [ 21 - { 22 - "collapsed": false, 23 - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, 24 - "id": 100, 25 - "title": "Overview", 26 - "type": "row" 27 - }, 28 - { 29 - "datasource": { "type": "loki", "uid": "Loki" }, 30 - "fieldConfig": { 31 - "defaults": { 32 - "color": { "mode": "thresholds" }, 33 - "thresholds": { 34 - "steps": [{ "color": "green", "value": null }] 35 - } 36 - }, 37 - "overrides": [] 38 - }, 39 - "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, 40 - "id": 1, 41 - "options": { 42 - "colorMode": "background", 43 - "graphMode": "area", 44 - "justifyMode": "auto", 45 - "orientation": "auto", 46 - "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 47 - "textMode": "auto" 48 - }, 49 - "title": "Total Requests", 50 - "type": "stat", 51 - "targets": [ 52 - { 53 - "datasource": { "type": "loki", "uid": "Loki" }, 54 - "expr": "count_over_time({${log_selector}} |= `HTTP request` [$__range])", 55 - "refId": "A" 56 - } 57 - ] 58 - }, 59 - { 60 - "datasource": { "type": "loki", "uid": "Loki" }, 61 - "fieldConfig": { 62 - "defaults": { 63 - "color": { "mode": "thresholds" }, 64 - "thresholds": { 65 - "steps": [ 66 - { "color": "green", "value": null }, 67 - { "color": "yellow", "value": 1 }, 68 - { "color": "red", "value": 10 } 69 - ] 70 - } 71 - }, 72 - "overrides": [] 73 - }, 74 - "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, 75 - "id": 2, 76 - "options": { 77 - "colorMode": "background", 78 - "graphMode": "area", 79 - "justifyMode": "auto", 80 - "orientation": "auto", 81 - "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 82 - "textMode": "auto" 83 - }, 84 - "title": "5xx Errors", 85 - "type": "stat", 86 - "targets": [ 87 - { 88 - "datasource": { "type": "loki", "uid": "Loki" }, 89 - "expr": "count_over_time({${log_selector}} |= `HTTP request` | json | status >= 500 [$__range])", 90 - "refId": "A" 91 - } 92 - ] 93 - }, 94 - { 95 - "datasource": { "type": "loki", "uid": "Loki" }, 96 - "fieldConfig": { 97 - "defaults": { 98 - "color": { "mode": "thresholds" }, 99 - "thresholds": { 100 - "steps": [ 101 - { "color": "green", "value": null }, 102 - { "color": "yellow", "value": 10 }, 103 - { "color": "orange", "value": 50 } 104 - ] 105 - } 106 - }, 107 - "overrides": [] 108 - }, 109 - "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, 110 - "id": 3, 111 - "options": { 112 - "colorMode": "background", 113 - "graphMode": "area", 114 - "justifyMode": "auto", 115 - "orientation": "auto", 116 - "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 117 - "textMode": "auto" 118 - }, 119 - "title": "4xx Errors", 120 - "type": "stat", 121 - "targets": [ 122 - { 123 - "datasource": { "type": "loki", "uid": "Loki" }, 124 - "expr": "count_over_time({${log_selector}} |= `HTTP request` | json | status >= 400 | status < 500 [$__range])", 125 - "refId": "A" 126 - } 127 - ] 128 - }, 129 - { 130 - "datasource": { "type": "loki", "uid": "Loki" }, 131 - "fieldConfig": { 132 - "defaults": { 133 - "color": { "mode": "thresholds" }, 134 - "thresholds": { 135 - "steps": [{ "color": "blue", "value": null }] 136 - } 137 - }, 138 - "overrides": [] 139 - }, 140 - "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, 141 - "id": 4, 142 - "options": { 143 - "colorMode": "background", 144 - "graphMode": "none", 145 - "justifyMode": "auto", 146 - "orientation": "auto", 147 - "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 148 - "textMode": "auto" 149 - }, 150 - "title": "Unique Users", 151 - "type": "stat", 152 - "targets": [ 153 - { 154 - "datasource": { "type": "loki", "uid": "Loki" }, 155 - "expr": "count_over_time({${log_selector}} |= `User logged in successfully` [$__range])", 156 - "refId": "A" 157 - } 158 - ] 159 - }, 160 - { 161 - "datasource": { "type": "loki", "uid": "Loki" }, 162 - "fieldConfig": { 163 - "defaults": { 164 - "color": { "mode": "thresholds" }, 165 - "thresholds": { 166 - "steps": [{ "color": "purple", "value": null }] 167 - } 168 - }, 169 - "overrides": [] 170 - }, 171 - "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, 172 - "id": 5, 173 - "options": { 174 - "colorMode": "background", 175 - "graphMode": "area", 176 - "justifyMode": "auto", 177 - "orientation": "auto", 178 - "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 179 - "textMode": "auto" 180 - }, 181 - "title": "Reports Filed", 182 - "type": "stat", 183 - "targets": [ 184 - { 185 - "datasource": { "type": "loki", "uid": "Loki" }, 186 - "expr": "count_over_time({${log_selector}} |= `Report created successfully` [$__range])", 187 - "refId": "A" 188 - } 189 - ] 190 - }, 191 - { 192 - "datasource": { "type": "loki", "uid": "Loki" }, 193 - "fieldConfig": { 194 - "defaults": { 195 - "color": { "mode": "thresholds" }, 196 - "thresholds": { 197 - "steps": [{ "color": "orange", "value": null }] 198 - } 199 - }, 200 - "overrides": [] 201 - }, 202 - "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, 203 - "id": 6, 204 - "options": { 205 - "colorMode": "background", 206 - "graphMode": "area", 207 - "justifyMode": "auto", 208 - "orientation": "auto", 209 - "reduceOptions": { "calcs": ["count"], "fields": "", "values": false }, 210 - "textMode": "auto" 211 - }, 212 - "title": "Join Requests", 213 - "type": "stat", 214 - "targets": [ 215 - { 216 - "datasource": { "type": "loki", "uid": "Loki" }, 217 - "expr": "count_over_time({${log_selector}} |= `Join request saved` [$__range])", 218 - "refId": "A" 219 - } 220 - ] 221 - }, 222 - { 223 - "collapsed": false, 224 - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, 225 - "id": 101, 226 - "title": "HTTP Traffic", 227 - "type": "row" 228 - }, 229 - { 230 - "datasource": { "type": "loki", "uid": "Loki" }, 231 - "fieldConfig": { 232 - "defaults": { 233 - "color": { "mode": "palette-classic" }, 234 - "custom": { 235 - "axisBorderShow": false, 236 - "drawStyle": "bars", 237 - "fillOpacity": 80, 238 - "stacking": { "mode": "normal" }, 239 - "lineWidth": 0, 240 - "pointSize": 5 241 - } 242 - }, 243 - "overrides": [] 244 - }, 245 - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, 246 - "id": 10, 247 - "options": { 248 - "legend": { "displayMode": "list", "placement": "bottom" }, 249 - "tooltip": { "mode": "multi" } 250 - }, 251 - "title": "Requests by Status", 252 - "type": "timeseries", 253 - "targets": [ 254 - { 255 - "datasource": { "type": "loki", "uid": "Loki" }, 256 - "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 200 | status < 300 [$__auto]))", 257 - "legendFormat": "2xx", 258 - "refId": "A" 259 - }, 260 - { 261 - "datasource": { "type": "loki", "uid": "Loki" }, 262 - "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 300 | status < 400 [$__auto]))", 263 - "legendFormat": "3xx", 264 - "refId": "B" 265 - }, 266 - { 267 - "datasource": { "type": "loki", "uid": "Loki" }, 268 - "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 400 | status < 500 [$__auto]))", 269 - "legendFormat": "4xx", 270 - "refId": "C" 271 - }, 272 - { 273 - "datasource": { "type": "loki", "uid": "Loki" }, 274 - "expr": "sum by (status) (count_over_time({${log_selector}} |= `HTTP request` | json | status >= 500 [$__auto]))", 275 - "legendFormat": "5xx", 276 - "refId": "D" 277 - } 278 - ] 279 - }, 280 - { 281 - "datasource": { "type": "loki", "uid": "Loki" }, 282 - "fieldConfig": { 283 - "defaults": { 284 - "color": { "mode": "palette-classic" }, 285 - "custom": { 286 - "axisBorderShow": false, 287 - "drawStyle": "bars", 288 - "fillOpacity": 60, 289 - "stacking": { "mode": "normal" }, 290 - "lineWidth": 0, 291 - "pointSize": 5 292 - } 293 - }, 294 - "overrides": [] 295 - }, 296 - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, 297 - "id": 11, 298 - "options": { 299 - "legend": { "displayMode": "list", "placement": "bottom" }, 300 - "tooltip": { "mode": "multi" } 301 - }, 302 - "title": "Requests by Method", 303 - "type": "timeseries", 304 - "targets": [ 305 - { 306 - "datasource": { "type": "loki", "uid": "Loki" }, 307 - "expr": "sum by (method) (count_over_time({${log_selector}} |= `HTTP request` | json [$__auto]))", 308 - "legendFormat": "{{method}}", 309 - "refId": "A" 310 - } 311 - ] 312 - }, 313 - { 314 - "datasource": { "type": "loki", "uid": "Loki" }, 315 - "fieldConfig": { 316 - "defaults": { 317 - "color": { "mode": "palette-classic" }, 318 - "custom": { 319 - "axisBorderShow": false, 320 - "drawStyle": "bars", 321 - "fillOpacity": 60, 322 - "stacking": { "mode": "normal" }, 323 - "lineWidth": 0, 324 - "pointSize": 5 325 - } 326 - }, 327 - "overrides": [] 328 - }, 329 - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, 330 - "id": 12, 331 - "options": { 332 - "legend": { "displayMode": "list", "placement": "bottom" }, 333 - "tooltip": { "mode": "multi" } 334 - }, 335 - "title": "Top Paths", 336 - "type": "timeseries", 337 - "targets": [ 338 - { 339 - "datasource": { "type": "loki", "uid": "Loki" }, 340 - "expr": "sum by (path) (count_over_time({${log_selector}} |= `HTTP request` | json | path !~ `/static/.*` [$__auto])) ", 341 - "legendFormat": "{{path}}", 342 - "refId": "A" 343 - } 344 - ] 345 - }, 346 - { 347 - "datasource": { "type": "loki", "uid": "Loki" }, 348 - "fieldConfig": { 349 - "defaults": { 350 - "color": { "mode": "palette-classic" }, 351 - "custom": { 352 - "axisBorderShow": false, 353 - "drawStyle": "line", 354 - "fillOpacity": 20, 355 - "lineWidth": 2, 356 - "pointSize": 5 357 - }, 358 - "unit": "ms" 359 - }, 360 - "overrides": [] 361 - }, 362 - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, 363 - "id": 13, 364 - "options": { 365 - "legend": { "displayMode": "list", "placement": "bottom" }, 366 - "tooltip": { "mode": "multi" } 367 - }, 368 - "title": "Response Time (avg per interval)", 369 - "type": "timeseries", 370 - "targets": [ 371 - { 372 - "datasource": { "type": "loki", "uid": "Loki" }, 373 - "expr": "avg_over_time({${log_selector}} |= `HTTP request` | json | path !~ `/static/.*` | unwrap duration [$__auto]) / 1000000", 374 - "legendFormat": "avg latency", 375 - "refId": "A" 376 - } 377 - ] 378 - }, 379 - { 380 - "collapsed": false, 381 - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 }, 382 - "id": 102, 383 - "title": "Firehose", 384 - "type": "row" 385 - }, 386 - { 387 - "datasource": { "type": "loki", "uid": "Loki" }, 388 - "fieldConfig": { 389 - "defaults": { 390 - "color": { "mode": "palette-classic" }, 391 - "custom": { 392 - "axisBorderShow": false, 393 - "drawStyle": "bars", 394 - "fillOpacity": 60, 395 - "stacking": { "mode": "normal" }, 396 - "lineWidth": 0, 397 - "pointSize": 5 398 - } 399 - }, 400 - "overrides": [] 401 - }, 402 - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 23 }, 403 - "id": 20, 404 - "options": { 405 - "legend": { "displayMode": "list", "placement": "bottom" }, 406 - "tooltip": { "mode": "multi" } 407 - }, 408 - "title": "Firehose Events by Collection", 409 - "type": "timeseries", 410 - "targets": [ 411 - { 412 - "datasource": { "type": "loki", "uid": "Loki" }, 413 - "expr": "sum by (collection) (count_over_time({${log_selector}} |= `firehose: processing event` | json [$__auto]))", 414 - "legendFormat": "{{collection}}", 415 - "refId": "A" 416 - } 417 - ] 418 - }, 419 - { 420 - "datasource": { "type": "loki", "uid": "Loki" }, 421 - "fieldConfig": { 422 - "defaults": { 423 - "color": { "mode": "palette-classic" }, 424 - "custom": { 425 - "axisBorderShow": false, 426 - "drawStyle": "bars", 427 - "fillOpacity": 60, 428 - "stacking": { "mode": "normal" }, 429 - "lineWidth": 0, 430 - "pointSize": 5 431 - } 432 - }, 433 - "overrides": [] 434 - }, 435 - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 23 }, 436 - "id": 21, 437 - "options": { 438 - "legend": { "displayMode": "list", "placement": "bottom" }, 439 - "tooltip": { "mode": "multi" } 440 - }, 441 - "title": "Firehose Events by Operation", 442 - "type": "timeseries", 443 - "targets": [ 444 - { 445 - "datasource": { "type": "loki", "uid": "Loki" }, 446 - "expr": "sum by (operation) (count_over_time({${log_selector}} |= `firehose: processing event` | json [$__auto]))", 447 - "legendFormat": "{{operation}}", 448 - "refId": "A" 449 - } 450 - ] 451 - }, 452 - { 453 - "datasource": { "type": "loki", "uid": "Loki" }, 454 - "fieldConfig": { 455 - "defaults": { 456 - "color": { "mode": "thresholds" }, 457 - "thresholds": { 458 - "steps": [ 459 - { "color": "green", "value": null }, 460 - { "color": "yellow", "value": 1 }, 461 - { "color": "red", "value": 5 } 462 - ] 463 - } 464 - }, 465 - "overrides": [] 466 - }, 467 - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 31 }, 468 - "id": 22, 469 - "options": { 470 - "legend": { "displayMode": "list", "placement": "bottom" }, 471 - "tooltip": { "mode": "multi" } 472 - }, 473 - "title": "Firehose Errors", 474 - "type": "timeseries", 475 - "targets": [ 476 - { 477 - "datasource": { "type": "loki", "uid": "Loki" }, 478 - "expr": "count_over_time({${log_selector}} |= `firehose:` |~ `\"level\":\"(warn|error)\"` [$__auto])", 479 - "legendFormat": "errors", 480 - "refId": "A" 481 - } 482 - ] 483 - }, 484 - { 485 - "datasource": { "type": "loki", "uid": "Loki" }, 486 - "fieldConfig": { 487 - "defaults": { 488 - "color": { "mode": "palette-classic" }, 489 - "custom": { 490 - "axisBorderShow": false, 491 - "drawStyle": "bars", 492 - "fillOpacity": 60, 493 - "lineWidth": 0, 494 - "pointSize": 5 495 - } 496 - }, 497 - "overrides": [] 498 - }, 499 - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 31 }, 500 - "id": 23, 501 - "options": { 502 - "legend": { "displayMode": "list", "placement": "bottom" }, 503 - "tooltip": { "mode": "multi" } 504 - }, 505 - "title": "Backfills", 506 - "type": "timeseries", 507 - "targets": [ 508 - { 509 - "datasource": { "type": "loki", "uid": "Loki" }, 510 - "expr": "count_over_time({${log_selector}} |= `backfill complete` [$__auto])", 511 - "legendFormat": "completed", 512 - "refId": "A" 513 - }, 514 - { 515 - "datasource": { "type": "loki", "uid": "Loki" }, 516 - "expr": "count_over_time({${log_selector}} |= `backfilling user records` [$__auto])", 517 - "legendFormat": "started", 518 - "refId": "B" 519 - } 520 - ] 521 - }, 522 - { 523 - "collapsed": false, 524 - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 39 }, 525 - "id": 103, 526 - "title": "Authentication & Users", 527 - "type": "row" 528 - }, 529 - { 530 - "datasource": { "type": "loki", "uid": "Loki" }, 531 - "fieldConfig": { 532 - "defaults": { 533 - "color": { "mode": "palette-classic" }, 534 - "custom": { 535 - "axisBorderShow": false, 536 - "drawStyle": "bars", 537 - "fillOpacity": 60, 538 - "lineWidth": 0, 539 - "pointSize": 5 540 - } 541 - }, 542 - "overrides": [] 543 - }, 544 - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 40 }, 545 - "id": 30, 546 - "options": { 547 - "legend": { "displayMode": "list", "placement": "bottom" }, 548 - "tooltip": { "mode": "multi" } 549 - }, 550 - "title": "Logins", 551 - "type": "timeseries", 552 - "targets": [ 553 - { 554 - "datasource": { "type": "loki", "uid": "Loki" }, 555 - "expr": "count_over_time({${log_selector}} |= `User logged in successfully` [$__auto])", 556 - "legendFormat": "successful logins", 557 - "refId": "A" 558 - }, 559 - { 560 - "datasource": { "type": "loki", "uid": "Loki" }, 561 - "expr": "count_over_time({${log_selector}} |= `Failed to initiate login` [$__auto])", 562 - "legendFormat": "failed logins", 563 - "refId": "B" 564 - }, 565 - { 566 - "datasource": { "type": "loki", "uid": "Loki" }, 567 - "expr": "count_over_time({${log_selector}} |= `Failed to complete OAuth flow` [$__auto])", 568 - "legendFormat": "failed OAuth callbacks", 569 - "refId": "C" 570 - } 571 - ] 572 - }, 573 - { 574 - "datasource": { "type": "loki", "uid": "Loki" }, 575 - "fieldConfig": { 576 - "defaults": { 577 - "color": { "mode": "palette-classic" }, 578 - "custom": { 579 - "axisBorderShow": false, 580 - "drawStyle": "bars", 581 - "fillOpacity": 60, 582 - "lineWidth": 0, 583 - "pointSize": 5 584 - } 585 - }, 586 - "overrides": [] 587 - }, 588 - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 40 }, 589 - "id": 31, 590 - "options": { 591 - "legend": { "displayMode": "list", "placement": "bottom" }, 592 - "tooltip": { "mode": "multi" } 593 - }, 594 - "title": "Join Requests & Invites", 595 - "type": "timeseries", 596 - "targets": [ 597 - { 598 - "datasource": { "type": "loki", "uid": "Loki" }, 599 - "expr": "count_over_time({${log_selector}} |= `Join request saved` [$__auto])", 600 - "legendFormat": "join requests", 601 - "refId": "A" 602 - }, 603 - { 604 - "datasource": { "type": "loki", "uid": "Loki" }, 605 - "expr": "count_over_time({${log_selector}} |= `Invite code created` [$__auto])", 606 - "legendFormat": "invites created", 607 - "refId": "B" 608 - }, 609 - { 610 - "datasource": { "type": "loki", "uid": "Loki" }, 611 - "expr": "count_over_time({${log_selector}} |= `Account created` [$__auto])", 612 - "legendFormat": "accounts created", 613 - "refId": "C" 614 - } 615 - ] 616 - }, 617 - { 618 - "collapsed": false, 619 - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 48 }, 620 - "id": 104, 621 - "title": "Moderation", 622 - "type": "row" 623 - }, 624 - { 625 - "datasource": { "type": "loki", "uid": "Loki" }, 626 - "fieldConfig": { 627 - "defaults": { 628 - "color": { "mode": "palette-classic" }, 629 - "custom": { 630 - "axisBorderShow": false, 631 - "drawStyle": "bars", 632 - "fillOpacity": 60, 633 - "lineWidth": 0, 634 - "pointSize": 5 635 - } 636 - }, 637 - "overrides": [] 638 - }, 639 - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 49 }, 640 - "id": 40, 641 - "options": { 642 - "legend": { "displayMode": "list", "placement": "bottom" }, 643 - "tooltip": { "mode": "multi" } 644 - }, 645 - "title": "Moderation Actions", 646 - "type": "timeseries", 647 - "targets": [ 648 - { 649 - "datasource": { "type": "loki", "uid": "Loki" }, 650 - "expr": "count_over_time({${log_selector}} |= `Report created successfully` [$__auto])", 651 - "legendFormat": "reports", 652 - "refId": "A" 653 - }, 654 - { 655 - "datasource": { "type": "loki", "uid": "Loki" }, 656 - "expr": "count_over_time({${log_selector}} |= `Record hidden successfully` [$__auto])", 657 - "legendFormat": "records hidden", 658 - "refId": "B" 659 - }, 660 - { 661 - "datasource": { "type": "loki", "uid": "Loki" }, 662 - "expr": "count_over_time({${log_selector}} |= `Record unhidden successfully` [$__auto])", 663 - "legendFormat": "records unhidden", 664 - "refId": "C" 665 - }, 666 - { 667 - "datasource": { "type": "loki", "uid": "Loki" }, 668 - "expr": "count_over_time({${log_selector}} |= `User blocked successfully` [$__auto])", 669 - "legendFormat": "users blocked", 670 - "refId": "D" 671 - } 672 - ] 673 - }, 674 - { 675 - "datasource": { "type": "loki", "uid": "Loki" }, 676 - "fieldConfig": { 677 - "defaults": { 678 - "color": { "mode": "palette-classic" }, 679 - "custom": { 680 - "axisBorderShow": false, 681 - "drawStyle": "bars", 682 - "fillOpacity": 60, 683 - "lineWidth": 0, 684 - "pointSize": 5 685 - } 686 - }, 687 - "overrides": [] 688 - }, 689 - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 49 }, 690 - "id": 41, 691 - "options": { 692 - "legend": { "displayMode": "list", "placement": "bottom" }, 693 - "tooltip": { "mode": "multi" } 694 - }, 695 - "title": "Permission Denials", 696 - "type": "timeseries", 697 - "targets": [ 698 - { 699 - "datasource": { "type": "loki", "uid": "Loki" }, 700 - "expr": "count_over_time({${log_selector}} |= `Denied:` [$__auto])", 701 - "legendFormat": "denied", 702 - "refId": "A" 703 - } 704 - ] 705 - }, 706 - { 707 - "collapsed": false, 708 - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 57 }, 709 - "id": 105, 710 - "title": "PDS & ATProto", 711 - "type": "row" 712 - }, 713 - { 714 - "datasource": { "type": "loki", "uid": "Loki" }, 715 - "fieldConfig": { 716 - "defaults": { 717 - "color": { "mode": "palette-classic" }, 718 - "custom": { 719 - "axisBorderShow": false, 720 - "drawStyle": "bars", 721 - "fillOpacity": 60, 722 - "stacking": { "mode": "normal" }, 723 - "lineWidth": 0, 724 - "pointSize": 5 725 - } 726 - }, 727 - "overrides": [] 728 - }, 729 - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 58 }, 730 - "id": 50, 731 - "options": { 732 - "legend": { "displayMode": "list", "placement": "bottom" }, 733 - "tooltip": { "mode": "multi" } 734 - }, 735 - "title": "PDS Requests by Method", 736 - "type": "timeseries", 737 - "targets": [ 738 - { 739 - "datasource": { "type": "loki", "uid": "Loki" }, 740 - "expr": "sum by (method) (count_over_time({${log_selector}} |= `PDS request completed` | json [$__auto]))", 741 - "legendFormat": "{{method}}", 742 - "refId": "A" 743 - } 744 - ] 745 - }, 746 - { 747 - "datasource": { "type": "loki", "uid": "Loki" }, 748 - "fieldConfig": { 749 - "defaults": { 750 - "color": { "mode": "palette-classic" }, 751 - "custom": { 752 - "axisBorderShow": false, 753 - "drawStyle": "line", 754 - "fillOpacity": 20, 755 - "lineWidth": 2, 756 - "pointSize": 5 757 - }, 758 - "unit": "ms" 759 - }, 760 - "overrides": [] 761 - }, 762 - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 58 }, 763 - "id": 51, 764 - "options": { 765 - "legend": { "displayMode": "list", "placement": "bottom" }, 766 - "tooltip": { "mode": "multi" } 767 - }, 768 - "title": "PDS Latency", 769 - "type": "timeseries", 770 - "targets": [ 771 - { 772 - "datasource": { "type": "loki", "uid": "Loki" }, 773 - "expr": "avg_over_time({${log_selector}} |= `PDS request completed` | json | unwrap duration [$__auto]) / 1000000", 774 - "legendFormat": "avg", 775 - "refId": "A" 776 - } 777 - ] 778 - }, 779 - { 780 - "datasource": { "type": "loki", "uid": "Loki" }, 781 - "fieldConfig": { 782 - "defaults": { 783 - "color": { "mode": "thresholds" }, 784 - "thresholds": { 785 - "steps": [ 786 - { "color": "green", "value": null }, 787 - { "color": "red", "value": 1 } 788 - ] 789 - } 790 - }, 791 - "overrides": [] 792 - }, 793 - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 66 }, 794 - "id": 52, 795 - "options": { 796 - "legend": { "displayMode": "list", "placement": "bottom" }, 797 - "tooltip": { "mode": "multi" } 798 - }, 799 - "title": "PDS Errors", 800 - "type": "timeseries", 801 - "targets": [ 802 - { 803 - "datasource": { "type": "loki", "uid": "Loki" }, 804 - "expr": "count_over_time({${log_selector}} |= `PDS request failed` [$__auto])", 805 - "legendFormat": "PDS failures", 806 - "refId": "A" 807 - } 808 - ] 809 - }, 810 - { 811 - "datasource": { "type": "loki", "uid": "Loki" }, 812 - "fieldConfig": { 813 - "defaults": { 814 - "color": { "mode": "palette-classic" }, 815 - "custom": { 816 - "axisBorderShow": false, 817 - "drawStyle": "bars", 818 - "fillOpacity": 60, 819 - "stacking": { "mode": "normal" }, 820 - "lineWidth": 0, 821 - "pointSize": 5 822 - } 823 - }, 824 - "overrides": [] 825 - }, 826 - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 66 }, 827 - "id": 53, 828 - "options": { 829 - "legend": { "displayMode": "list", "placement": "bottom" }, 830 - "tooltip": { "mode": "multi" } 831 - }, 832 - "title": "PDS Requests by Collection", 833 - "type": "timeseries", 834 - "targets": [ 835 - { 836 - "datasource": { "type": "loki", "uid": "Loki" }, 837 - "expr": "sum by (collection) (count_over_time({${log_selector}} |= `PDS request completed` | json [$__auto]))", 838 - "legendFormat": "{{collection}}", 839 - "refId": "A" 840 - } 841 - ] 842 - }, 843 - { 844 - "collapsed": false, 845 - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 74 }, 846 - "id": 106, 847 - "title": "Errors & Warnings", 848 - "type": "row" 849 - }, 850 - { 851 - "datasource": { "type": "loki", "uid": "Loki" }, 852 - "fieldConfig": { 853 - "defaults": { 854 - "color": { "mode": "palette-classic" }, 855 - "custom": { 856 - "axisBorderShow": false, 857 - "drawStyle": "bars", 858 - "fillOpacity": 80, 859 - "stacking": { "mode": "normal" }, 860 - "lineWidth": 0, 861 - "pointSize": 5 862 - } 863 - }, 864 - "overrides": [ 865 - { 866 - "matcher": { "id": "byName", "options": "error" }, 867 - "properties": [ 868 - { 869 - "id": "color", 870 - "value": { "fixedColor": "red", "mode": "fixed" } 871 - } 872 - ] 873 - }, 874 - { 875 - "matcher": { "id": "byName", "options": "warn" }, 876 - "properties": [ 877 - { 878 - "id": "color", 879 - "value": { "fixedColor": "yellow", "mode": "fixed" } 880 - } 881 - ] 882 - } 883 - ] 884 - }, 885 - "gridPos": { "h": 8, "w": 24, "x": 0, "y": 75 }, 886 - "id": 60, 887 - "options": { 888 - "legend": { "displayMode": "list", "placement": "bottom" }, 889 - "tooltip": { "mode": "multi" } 890 - }, 891 - "title": "Errors & Warnings Over Time", 892 - "type": "timeseries", 893 - "targets": [ 894 - { 895 - "datasource": { "type": "loki", "uid": "Loki" }, 896 - "expr": "count_over_time({${log_selector}} |~ `\"level\":\"error\"` [$__auto])", 897 - "legendFormat": "error", 898 - "refId": "A" 899 - }, 900 - { 901 - "datasource": { "type": "loki", "uid": "Loki" }, 902 - "expr": "count_over_time({${log_selector}} |~ `\"level\":\"warn\"` [$__auto])", 903 - "legendFormat": "warn", 904 - "refId": "B" 905 - } 906 - ] 907 - }, 908 - { 909 - "datasource": { "type": "loki", "uid": "Loki" }, 910 - "gridPos": { "h": 12, "w": 24, "x": 0, "y": 83 }, 911 - "id": 61, 912 - "options": { 913 - "dedupStrategy": "none", 914 - "enableLogDetails": true, 915 - "prettifyLogMessage": true, 916 - "showCommonLabels": false, 917 - "showLabels": false, 918 - "showTime": true, 919 - "sortOrder": "Descending", 920 - "wrapLogMessage": true 921 - }, 922 - "title": "Recent Errors", 923 - "type": "logs", 924 - "targets": [ 925 - { 926 - "datasource": { "type": "loki", "uid": "Loki" }, 927 - "expr": "{${log_selector}} |~ `\"level\":\"error\"`", 928 - "refId": "A" 929 - } 930 - ] 931 - } 932 - ], 933 - "schemaVersion": 39, 934 - "tags": ["arabica", "loki", "logs"], 935 - "templating": { 936 - "list": [ 937 - { 938 - "current": { 939 - "selected": false, 940 - "text": "unit=\"arabica.service\"", 941 - "value": "unit=\"arabica.service\"" 942 - }, 943 - "description": "Loki stream selector for Arabica logs. Common values: unit=\"arabica.service\" (journald via Promtail), syslog_identifier=\"arabica\" (journald syslog), app=\"arabica\" (Docker/custom labels)", 944 - "hide": 0, 945 - "label": "Log Selector", 946 - "name": "log_selector", 947 - "options": [ 948 - { 949 - "selected": true, 950 - "text": "unit=\"arabica.service\"", 951 - "value": "unit=\"arabica.service\"" 952 - }, 953 - { 954 - "selected": false, 955 - "text": "syslog_identifier=\"arabica\"", 956 - "value": "syslog_identifier=\"arabica\"" 957 - }, 958 - { 959 - "selected": false, 960 - "text": "app=\"arabica\"", 961 - "value": "app=\"arabica\"" 962 - } 963 - ], 964 - "query": "unit=\"arabica.service\",syslog_identifier=\"arabica\",app=\"arabica\"", 965 - "skipUrlSync": false, 966 - "type": "custom" 967 - } 968 - ] 969 - }, 970 - "time": { "from": "now-24h", "to": "now" }, 971 - "timepicker": {}, 972 - "timezone": "", 973 - "title": "Arabica - Log Metrics", 974 - "uid": "arabica-logs", 975 - "version": 1 976 - }
-6
internal/atproto/records.go
··· 31 31 if recipe.WaterAmount > 0 { 32 32 record["waterAmount"] = int(recipe.WaterAmount * 10) 33 33 } 34 - if recipe.GrindSize != "" { 35 - record["grindSize"] = recipe.GrindSize 36 - } 37 34 if recipe.Notes != "" { 38 35 record["notes"] = recipe.Notes 39 36 } ··· 91 88 } 92 89 if waterAmount, ok := record["waterAmount"].(float64); ok { 93 90 recipe.WaterAmount = waterAmount / 10.0 94 - } 95 - if grindSize, ok := record["grindSize"].(string); ok { 96 - recipe.GrindSize = grindSize 97 91 } 98 92 if notes, ok := record["notes"].(string); ok { 99 93 recipe.Notes = notes
-2
internal/atproto/store.go
··· 1677 1677 BrewerType: req.BrewerType, 1678 1678 CoffeeAmount: req.CoffeeAmount, 1679 1679 WaterAmount: req.WaterAmount, 1680 - GrindSize: req.GrindSize, 1681 1680 Notes: req.Notes, 1682 1681 SourceRef: req.SourceRef, 1683 1682 CreatedAt: time.Now(), ··· 1933 1932 BrewerType: req.BrewerType, 1934 1933 CoffeeAmount: req.CoffeeAmount, 1935 1934 WaterAmount: req.WaterAmount, 1936 - GrindSize: req.GrindSize, 1937 1935 Notes: req.Notes, 1938 1936 SourceRef: existing.SourceRef, 1939 1937 CreatedAt: existing.CreatedAt,
-4
internal/handlers/entity_views.go
··· 914 914 if recipe.WaterAmount > 0 { 915 915 descParts = append(descParts, fmt.Sprintf("%.0fg water", recipe.WaterAmount)) 916 916 } 917 - if recipe.GrindSize != "" { 918 - descParts = append(descParts, recipe.GrindSize+" grind") 919 - } 920 - 921 917 var ogDescription string 922 918 if len(descParts) > 0 { 923 919 ogDescription = strings.Join(descParts, " · ")
-4
internal/handlers/recipe.go
··· 31 31 Name: r.FormValue("name"), 32 32 BrewerRKey: r.FormValue("brewer_rkey"), 33 33 BrewerType: r.FormValue("brewer_type"), 34 - GrindSize: r.FormValue("grind_size"), 35 34 Notes: r.FormValue("notes"), 36 35 SourceRef: r.FormValue("source_ref"), 37 36 } ··· 95 94 Name: r.FormValue("name"), 96 95 BrewerRKey: r.FormValue("brewer_rkey"), 97 96 BrewerType: r.FormValue("brewer_type"), 98 - GrindSize: r.FormValue("grind_size"), 99 97 Notes: r.FormValue("notes"), 100 98 } 101 99 if v := r.FormValue("coffee_amount"); v != "" { ··· 223 221 BrewerRKey: brew.BrewerRKey, 224 222 CoffeeAmount: float64(brew.CoffeeAmount), 225 223 WaterAmount: float64(brew.WaterAmount), 226 - GrindSize: brew.GrindSize, 227 224 } 228 225 229 226 // Copy pours ··· 343 340 BrewerType: brewerType, 344 341 CoffeeAmount: sourceRecipe.CoffeeAmount, 345 342 WaterAmount: sourceRecipe.WaterAmount, 346 - GrindSize: sourceRecipe.GrindSize, 347 343 Notes: sourceRecipe.Notes, 348 344 SourceRef: sourceURI, 349 345 }
-9
internal/models/models.go
··· 100 100 BrewerType string `json:"brewer_type"` 101 101 CoffeeAmount float64 `json:"coffee_amount"` 102 102 WaterAmount float64 `json:"water_amount"` 103 - GrindSize string `json:"grind_size"` 104 103 Notes string `json:"notes"` 105 104 SourceRef string `json:"source_ref,omitempty"` 106 105 CreatedAt time.Time `json:"created_at"` ··· 230 229 BrewerType string `json:"brewer_type"` 231 230 CoffeeAmount float64 `json:"coffee_amount"` 232 231 WaterAmount float64 `json:"water_amount"` 233 - GrindSize string `json:"grind_size"` 234 232 Notes string `json:"notes"` 235 233 SourceRef string `json:"source_ref,omitempty"` 236 234 Pours []CreatePourData `json:"pours"` ··· 242 240 BrewerType string `json:"brewer_type"` 243 241 CoffeeAmount float64 `json:"coffee_amount"` 244 242 WaterAmount float64 `json:"water_amount"` 245 - GrindSize string `json:"grind_size"` 246 243 Notes string `json:"notes"` 247 244 Pours []CreatePourData `json:"pours"` 248 245 } ··· 472 469 if len(r.BrewerType) > MaxBrewerTypeLength { 473 470 return ErrFieldTooLong 474 471 } 475 - if len(r.GrindSize) > MaxGrindSizeLength { 476 - return ErrFieldTooLong 477 - } 478 472 if len(r.Notes) > MaxNotesLength { 479 473 return ErrNotesTooLong 480 474 } ··· 490 484 return ErrNameTooLong 491 485 } 492 486 if len(r.BrewerType) > MaxBrewerTypeLength { 493 - return ErrFieldTooLong 494 - } 495 - if len(r.GrindSize) > MaxGrindSizeLength { 496 487 return ErrFieldTooLong 497 488 } 498 489 if len(r.Notes) > MaxNotesLength {
-9
internal/web/components/dialog_modals.templ
··· 574 574 step="0.1" 575 575 class="w-full form-input" 576 576 /> 577 - <input 578 - type="text" 579 - name="grind_size" 580 - value={ getStringValue(recipe, "grind_size") } 581 - placeholder="Grind Size (e.g., Medium, 18, Fine)" 582 - class="w-full form-input" 583 - /> 584 577 <!-- Pours --> 585 578 <div> 586 579 <div class="flex items-center justify-between mb-2"> ··· 818 811 return e.Name 819 812 case "brewer_type": 820 813 return e.BrewerType 821 - case "grind_size": 822 - return e.GrindSize 823 814 case "notes": 824 815 return e.Notes 825 816 case "coffee_amount":
-8
internal/web/components/entity_tables.templ
··· 334 334 <th class="table-th whitespace-nowrap">Name</th> 335 335 <th class="table-th whitespace-nowrap">Coffee</th> 336 336 <th class="table-th whitespace-nowrap">Water</th> 337 - <th class="table-th whitespace-nowrap">Grind</th> 338 337 <th class="table-th whitespace-nowrap">Brewer</th> 339 338 if props.ShowActions { 340 339 <th class="table-th whitespace-nowrap">Actions</th> ··· 357 356 <td class="px-6 py-4 text-sm text-brown-900"> 358 357 if recipe.WaterAmount > 0 { 359 358 { fmt.Sprintf("%.1fg", recipe.WaterAmount) } 360 - } else { 361 - <span class="text-brown-400">-</span> 362 - } 363 - </td> 364 - <td class="px-6 py-4 text-sm text-brown-900"> 365 - if recipe.GrindSize != "" { 366 - { recipe.GrindSize } 367 359 } else { 368 360 <span class="text-brown-400">-</span> 369 361 }
-3
internal/web/pages/brew_view.templ
··· 328 328 if recipe.WaterAmount > 0 { 329 329 <span>💧 { fmt.Sprintf("%.1fg water", recipe.WaterAmount) }</span> 330 330 } 331 - if recipe.GrindSize != "" { 332 - <span>🔧 { recipe.GrindSize }</span> 333 - } 334 331 if recipe.BrewerObj != nil { 335 332 <span>🫖 { recipe.BrewerObj.Name }</span> 336 333 } else if recipe.BrewerType != "" {
-3
internal/web/pages/feed.templ
··· 445 445 if item.Recipe.WaterAmount > 0 { 446 446 <span class="inline-flex items-center gap-0.5">💧 { fmt.Sprintf("%.1fg", item.Recipe.WaterAmount) }</span> 447 447 } 448 - if item.Recipe.GrindSize != "" { 449 - <span class="inline-flex items-center gap-0.5">🔧 { item.Recipe.GrindSize }</span> 450 - } 451 448 if item.Recipe.BrewerObj != nil { 452 449 <span class="inline-flex items-center gap-0.5">🫖 { item.Recipe.BrewerObj.Name }</span> 453 450 } else if item.Recipe.BrewerType != "" {
+7 -12
internal/web/pages/recipe_explore.templ
··· 214 214 <span class="block text-sm font-medium text-brown-900" x-text="formatRatio(recipe)"></span> 215 215 </div> 216 216 </div> 217 - <!-- Grind (if present) --> 218 - <template x-if="recipe.grind_size"> 219 - <p class="text-xs text-brown-600 mb-3"> 220 - <span class="uppercase text-brown-500">Grind:</span> 221 - <span class="ml-1" x-text="recipe.grind_size"></span> 222 - </p> 223 - </template> 224 217 <!-- Counts row --> 225 218 <template x-if="recipe.brew_count > 0 || recipe.fork_count > 0"> 226 219 <div class="flex items-center gap-3 pt-2 border-t border-brown-200/60 text-xs text-brown-500"> ··· 324 317 <button @click="selectedRecipe = null" class="text-brown-500 hover:text-brown-700 text-lg font-bold">&times;</button> 325 318 </div> 326 319 </div> 327 - <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4"> 320 + <template x-if="getBrewerDisplay(selectedRecipe) !== '-'"> 321 + <div class="mb-4"> 322 + <span class="text-xs text-brown-600 uppercase">Brewer</span> 323 + <p class="font-medium text-brown-900" x-text="getBrewerDisplay(selectedRecipe)"></p> 324 + </div> 325 + </template> 326 + <div class="grid grid-cols-3 gap-4 mb-4"> 328 327 <div> 329 328 <span class="text-xs text-brown-600 uppercase">Coffee</span> 330 329 <p class="font-medium text-brown-900" x-text="selectedRecipe.coffee_amount > 0 ? selectedRecipe.coffee_amount.toFixed(1) + 'g' : '-'"></p> ··· 336 335 <div> 337 336 <span class="text-xs text-brown-600 uppercase">Ratio</span> 338 337 <p class="font-medium text-brown-900" x-text="formatRatio(selectedRecipe)"></p> 339 - </div> 340 - <div> 341 - <span class="text-xs text-brown-600 uppercase">Grind</span> 342 - <p class="font-medium text-brown-900" x-text="selectedRecipe.grind_size || '-'"></p> 343 338 </div> 344 339 </div> 345 340 <template x-if="selectedRecipe.pours && selectedRecipe.pours.length > 0">
-3
internal/web/pages/recipe_view.templ
··· 53 53 if props.Recipe.Ratio > 0 { 54 54 @components.DetailField(components.DetailFieldProps{Label: "⚖️ Ratio", Value: fmt.Sprintf("1:%.1f", props.Recipe.Ratio)}) 55 55 } 56 - if props.Recipe.GrindSize != "" { 57 - @components.DetailField(components.DetailFieldProps{Label: "🔧 Grind Size", Value: props.Recipe.GrindSize}) 58 - } 59 56 if props.Recipe.BrewerObj != nil { 60 57 @components.DetailField(components.DetailFieldProps{ 61 58 Label: "🫖 Brewer",
-5
lexicons/social.arabica.alpha.recipe.json
··· 35 35 "minimum": 0, 36 36 "description": "Amount of water in tenths of grams (e.g., 3000 = 300.0g)" 37 37 }, 38 - "grindSize": { 39 - "type": "string", 40 - "maxLength": 100, 41 - "description": "Grind size setting (can be numeric like '18' or descriptive like 'Medium-Fine')" 42 - }, 43 38 "pours": { 44 39 "type": "array", 45 40 "description": "Array of pour information for multi-pour methods",
-2
static/js/brew-form.js
··· 194 194 // Set or clear each field based on recipe data 195 195 this.setFormField(form, "coffee_amount", recipe.coffee_amount > 0 ? Math.round(recipe.coffee_amount) : ""); 196 196 this.setFormField(form, "water_amount", recipe.water_amount > 0 ? Math.round(recipe.water_amount) : ""); 197 - this.setFormField(form, "grind_size", recipe.grind_size || ""); 198 197 this.setFormField(form, "brewer_rkey", recipe.brewer_rkey || ""); 199 198 200 199 // Always reset pours, then apply recipe pours if present ··· 217 216 clearRecipeFields(form) { 218 217 this.setFormField(form, "coffee_amount", ""); 219 218 this.setFormField(form, "water_amount", ""); 220 - this.setFormField(form, "grind_size", ""); 221 219 this.setFormField(form, "brewer_rkey", ""); 222 220 this.pours = []; 223 221 },
+3
static/js/recipe-explore.js
··· 62 62 63 63 getBrewerDisplay(recipe) { 64 64 if (recipe.brewer_obj && recipe.brewer_obj.name) { 65 + if (recipe.brewer_type) { 66 + return recipe.brewer_obj.name + ' · ' + recipe.brewer_type; 67 + } 65 68 return recipe.brewer_obj.name; 66 69 } 67 70 return recipe.brewer_type || "-";