Skip to content

Commit 229924b

Browse files
httplib and some documented adjustments.
Signed-off-by: Aaron Layfield <aaron.layfield@gmail.com>
1 parent a9f0975 commit 229924b

24 files changed

Lines changed: 639 additions & 199 deletions

File tree

README.md

Lines changed: 70 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
# Dandy Dashboard
22

3-
A personal, modular dashboard built with a Go backend and Svelte frontend. Drop in the widgets you want — adding a new one is three files.
3+
A personal, modular dashboard built with a Go backend and Svelte frontend. Drop in the widgets you want — adding a new one is a few files each side.
4+
5+
Right now, this is entirely codified as opposed to pluggable within the browser.
6+
7+
This was made in conjunction with an AI Agent and purely being done as a way to refresh and relearn newer frameworks in conjunction with understanding the current agent model capabilities.
48

59
![Dashboard screenshot placeholder](docs/screenshot.png)
610

711
## Widgets
812

913
| Widget | Description |
1014
|---|---|
11-
| **Word of the Day** | Daily Japanese vocabulary with reading, JLPT level, meanings, and example sentences (via [Jotoba](https://jotoba.de)) |
12-
| **Upcoming Events** | Next 7 days from Google Calendar |
13-
| **Claude AI** | Streaming chat with Claude, your personal assistant |
15+
| **Word of the Day** | Daily Japanese vocabulary pulled from your WaniKani Apprentice/Guru items, with readings, meanings, and example sentences |
16+
| **Upcoming Events** | Next events from Google Calendar via a service account |
17+
| **Claude AI** | Streaming chat with Claude Opus 4.6, with server-side conversation history and adaptive thinking |
1418

1519
## Tech Stack
1620

17-
- **Backend** — Go 1.25 + [Echo](https://echo.labstack.com/)
21+
- **Backend** — Go 1.25, stdlib `net/http` (no framework)
1822
- **Frontend**[Svelte 5](https://svelte.dev/) + TypeScript + Vite + Tailwind CSS
19-
- **AI**[Anthropic API](https://docs.anthropic.com/) (Claude 3.5 Sonnet, streaming SSE)
20-
- **Calendar** — Google Calendar API (service account or OAuth2)
23+
- **AI**[Anthropic API](https://docs.anthropic.com/) (Claude Opus 4.6, streaming SSE)
24+
- **Calendar** — Google Calendar API (service account)
25+
- **Persistence**[bbolt](https://github.com/etcd-io/bbolt) embedded KV (default) or Redis
2126

2227
## Getting Started
2328

@@ -27,7 +32,7 @@ A personal, modular dashboard built with a Go backend and Svelte frontend. Drop
2732
git clone https://github.com/dandydeveloper/dandy-dashboard
2833
cd dandy-dashboard
2934
cp .env.example .env
30-
# Edit .env and add your ANTHROPIC_API_KEY at minimum
35+
# Edit .env — ANTHROPIC_API_KEY is required at minimum
3136
```
3237

3338
### 2. Run in development
@@ -44,42 +49,52 @@ make dev-frontend
4449

4550
Open [http://localhost:5173](http://localhost:5173).
4651

47-
### 3. Production build
48-
49-
```bash
50-
make build
51-
./bin/server
52-
```
52+
### 3. Production with Docker
5353

54-
Or with Docker:
5554
```bash
5655
docker compose up --build
5756
```
5857

58+
Frontend is served by Nginx on port 80. The backend API is internal — Nginx proxies `/api/*` to it.
59+
5960
## Configuration
6061

61-
| Variable | Required | Description |
62-
|---|---|---|
63-
| `ANTHROPIC_API_KEY` | Yes | From [console.anthropic.com](https://console.anthropic.com/keys) |
64-
| `GOOGLE_CREDENTIALS_JSON` | No | Path to service account JSON, or raw JSON string |
65-
| `GOOGLE_CALENDAR_ID` | No | Calendar ID (default: `primary`) |
66-
| `PORT` | No | Server port (default: `8080`) |
67-
| `ALLOWED_ORIGINS` | No | CORS origins (default: `http://localhost:5173`) |
68-
| `DASHBOARD_KEY` | No | Shared secret for `X-Dashboard-Key` header auth |
62+
All configuration is via environment variables (`.env` file locally, secrets manager in production).
63+
64+
| Variable | Required | Default | Description |
65+
|---|---|---|---|
66+
| `ANTHROPIC_API_KEY` | Yes || From [console.anthropic.com](https://console.anthropic.com/keys) |
67+
| `WANIKANI_API_TOKEN` | No || From [wanikani.com/settings/personal_access_tokens](https://www.wanikani.com/settings/personal_access_tokens) — enables WaniKani word source |
68+
| `GOOGLE_CREDENTIALS_JSON` | No || Path to service account JSON file, or raw JSON string |
69+
| `GOOGLE_CALENDAR_ID` | No | `primary` | Calendar ID from Google Calendar settings |
70+
| `PORT` | No | `8080` | Backend listen port |
71+
| `ALLOWED_ORIGINS` | No | `http://localhost:5173` | Comma-separated CORS origins |
72+
| `DASHBOARD_KEY` | No || Shared secret for `X-Dashboard-Key` header — **set this in production** |
73+
| `DATA_DIR` | No | `./data` | Directory for the embedded bbolt database |
74+
| `STORE_URL` | No || Redis URL (e.g. `redis://localhost:6379`) — overrides bbolt |
75+
76+
### Google Calendar setup
77+
78+
1. Create a project in [Google Cloud Console](https://console.cloud.google.com)
79+
2. Enable the **Google Calendar API**
80+
3. Create a **Service Account** — no IAM role needed
81+
4. Download the JSON key → set as `GOOGLE_CREDENTIALS_JSON`
82+
5. In Google Calendar, share your calendar with the service account email → **"See all event details"**
6983

7084
## Adding a Widget
7185

72-
The plugin system is explicit — no magic. Three steps each side.
86+
The plugin system is explicit — no magic. A few files each side.
7387

7488
**Backend** — create `internal/widgets/mywidget/`:
7589
```go
7690
// widget.go — implement the Widget interface
7791
func (w *Widget) Slug() string { return "mywidget" }
78-
func (w *Widget) RegisterRoutes(g *echo.Group) {
79-
g.GET("/data", w.handler.Data)
92+
93+
func (w *Widget) RegisterRoutes(mux *http.ServeMux) {
94+
mux.HandleFunc("GET /data", w.handler.Data)
8095
}
8196
```
82-
Then add one line to `cmd/server/main.go`:
97+
Then register in `cmd/server/main.go`:
8398
```go
8499
registry.Register(mywidget.New(cfg))
85100
```
@@ -91,42 +106,59 @@ export const myWidget: WidgetDescriptor = {
91106
id: 'mywidget',
92107
title: 'My Widget',
93108
description: '...',
94-
component: MyWidget, // Svelte component
109+
component: MyWidgetComponent,
95110
defaultSize: 'md',
96111
}
97112
```
98113
Then add one line to `web/src/widgets/registry.ts`:
99114
```typescript
100-
import { myWidget } from './mywidget'
101-
export const widgetRegistry = [..., myWidget]
115+
export const widgetRegistry = [japaneseWidget, calendarWidget, myWidget]
102116
```
103117

104-
Done — the widget appears in the grid automatically.
118+
The widget appears in the resizable grid automatically.
105119

106120
## Project Structure
107121

108122
```
109123
dandy-dashboard/
110-
├── cmd/server/main.go # Entry point — wires config, registry, Echo
124+
├── cmd/server/main.go # Entry point — wires config, registry, server
111125
├── internal/
112126
│ ├── config/ # Env-based configuration
127+
│ ├── httputil/ # JSON response helpers
128+
│ ├── middleware/ # Logger, recover, request ID, CORS, API key
129+
│ ├── store/ # bbolt / Redis abstraction
113130
│ ├── widget/ # Widget interface + registry
114131
│ └── widgets/
115-
│ ├── claude/ # Claude AI chat (streaming SSE)
116-
│ ├── japanese/ # Word of the day (Jotoba API + embedded wordlist)
132+
│ ├── claude/ # Claude AI chat (streaming SSE, session management)
133+
│ ├── japanese/ # Word of the day (WaniKani + Jotoba)
117134
│ └── calendar/ # Google Calendar events
135+
├── docker/
136+
│ ├── backend.Dockerfile
137+
│ ├── frontend.Dockerfile
138+
│ └── nginx.conf # Serves static files, proxies /api/* to backend
118139
└── web/src/
119140
├── widgets/
120141
│ ├── types.ts # WidgetDescriptor interface
121-
│ ├── registry.ts # All widgets registered here
142+
│ ├── registry.ts # All active widgets registered here
122143
│ ├── claude/
123144
│ ├── japanese/
124145
│ └── calendar/
125-
└── components/
126-
├── WidgetCard.svelte # Shared card shell (header + body)
127-
└── App.svelte # Dashboard grid layout
146+
├── components/
147+
│ ├── DashboardGrid.svelte # Resizable 12-column grid (layout persisted to localStorage)
148+
│ └── WidgetCard.svelte # Shared card shell
149+
└── stores/
150+
└── layout.ts # Grid layout state + localStorage persistence
128151
```
129152

153+
## Security
154+
155+
- API endpoints are optionally protected by a shared `X-Dashboard-Key` header (`DASHBOARD_KEY` env var) — **enable this if the dashboard is publicly reachable**
156+
- CORS is restricted to `ALLOWED_ORIGINS`
157+
- Request bodies are capped at 512 KB; messages at 32 KB
158+
- Chat sessions expire after 24 hours of inactivity
159+
- Containers run as non-root
160+
- Raw errors from external APIs are never forwarded to the client
161+
130162
## License
131163

132164
MIT

cmd/server/main.go

Lines changed: 27 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,24 @@ package main
22

33
import (
44
"log"
5+
"log/slog"
56
"net/http"
67
"os"
78
"strings"
89

910
"github.com/dandydeveloper/dandy-dashboard/internal/config"
11+
"github.com/dandydeveloper/dandy-dashboard/internal/httputil"
12+
"github.com/dandydeveloper/dandy-dashboard/internal/middleware"
1013
"github.com/dandydeveloper/dandy-dashboard/internal/store"
1114
"github.com/dandydeveloper/dandy-dashboard/internal/widget"
1215
"github.com/dandydeveloper/dandy-dashboard/internal/widgets/calendar"
1316
"github.com/dandydeveloper/dandy-dashboard/internal/widgets/claude"
1417
"github.com/dandydeveloper/dandy-dashboard/internal/widgets/japanese"
15-
"github.com/labstack/echo/v4"
16-
"github.com/labstack/echo/v4/middleware"
1718
)
1819

1920
func main() {
21+
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
22+
2023
cfg, err := config.Load()
2124
if err != nil {
2225
log.Fatalf("config: %v", err)
@@ -25,12 +28,11 @@ func main() {
2528
kv := mustOpenStore(cfg)
2629
defer kv.Close()
2730

28-
registry := buildRegistry(cfg, kv)
29-
30-
e := buildServer(cfg, registry)
31+
registry := buildRegistry(cfg, kv, logger)
32+
srv := buildServer(cfg, registry, logger)
3133

32-
log.Printf("Dandy Dashboard running on :%s", cfg.Port)
33-
if err := e.Start(":" + cfg.Port); err != nil && err != http.ErrServerClosed {
34+
logger.Info("server starting", "port", cfg.Port)
35+
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
3436
log.Fatalf("server: %v", err)
3537
}
3638
}
@@ -48,10 +50,10 @@ func mustOpenStore(cfg *config.Config) store.Store {
4850
return kv
4951
}
5052

51-
func buildRegistry(cfg *config.Config, kv store.Store) *widget.Registry {
53+
func buildRegistry(cfg *config.Config, kv store.Store, logger *slog.Logger) *widget.Registry {
5254
registry := &widget.Registry{}
5355

54-
registry.Register(claude.New(cfg.AnthropicAPIKey))
56+
registry.Register(claude.New(cfg.AnthropicAPIKey, logger))
5557

5658
japaneseWidget, err := japanese.New(kv, cfg.WaniKaniToken)
5759
if err != nil {
@@ -68,40 +70,25 @@ func buildRegistry(cfg *config.Config, kv store.Store) *widget.Registry {
6870
return registry
6971
}
7072

71-
func buildServer(cfg *config.Config, registry *widget.Registry) *echo.Echo {
72-
e := echo.New()
73-
e.HideBanner = true
74-
75-
e.Use(middleware.Logger())
76-
e.Use(middleware.Recover())
77-
e.Use(middleware.RequestID())
78-
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
79-
AllowOrigins: strings.Split(cfg.AllowedOrigins, ","),
80-
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions},
81-
AllowHeaders: []string{echo.HeaderContentType, "X-Dashboard-Key"},
82-
}))
83-
84-
if cfg.DashboardKey != "" {
85-
e.Use(apiKeyMiddleware(cfg.DashboardKey))
86-
}
73+
func buildServer(cfg *config.Config, registry *widget.Registry, logger *slog.Logger) *http.Server {
74+
mux := http.NewServeMux()
8775

88-
registry.Mount(e)
76+
registry.Mount(mux)
8977

90-
e.GET("/api/widgets", func(c echo.Context) error {
91-
return c.JSON(http.StatusOK, map[string]interface{}{"widgets": registry.Slugs()})
78+
mux.HandleFunc("GET /api/widgets", func(w http.ResponseWriter, r *http.Request) {
79+
httputil.WriteJSON(w, http.StatusOK, map[string]any{"widgets": registry.Slugs()})
9280
})
9381

94-
return e
95-
}
96-
97-
func apiKeyMiddleware(key string) echo.MiddlewareFunc {
98-
return func(next echo.HandlerFunc) echo.HandlerFunc {
99-
return func(c echo.Context) error {
100-
if strings.HasPrefix(c.Request().URL.Path, "/api/") &&
101-
c.Request().Header.Get("X-Dashboard-Key") != key {
102-
return echo.ErrUnauthorized
103-
}
104-
return next(c)
105-
}
82+
handler := middleware.Chain(mux,
83+
middleware.Recover(logger),
84+
middleware.RequestID(),
85+
middleware.Logger(logger),
86+
middleware.CORS(strings.Split(cfg.AllowedOrigins, ",")),
87+
middleware.APIKey(cfg.DashboardKey),
88+
)
89+
90+
return &http.Server{
91+
Addr: ":" + cfg.Port,
92+
Handler: handler,
10693
}
10794
}

docker/backend.Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
1010

1111
# Stage 2 — Runtime
1212
FROM alpine:3.20
13-
RUN apk add --no-cache ca-certificates tzdata
13+
RUN apk add --no-cache ca-certificates tzdata \
14+
&& addgroup -S app && adduser -S -G app app
1415
WORKDIR /app
1516
COPY --from=builder /server .
17+
RUN chown app:app /app/server
1618

1719
# /data is where dashboard.db lives when using the embedded bolt backend.
1820
VOLUME ["/data"]
1921

22+
USER app
2023
EXPOSE 8080
2124
ENTRYPOINT ["/app/server"]

docker/frontend.Dockerfile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
# syntax=docker/dockerfile:1
22

33
# Stage 1 — Build
4-
FROM node:24-alpine AS builder
4+
FROM node:24.14.0-alpine AS builder
55
WORKDIR /app
66
COPY web/package*.json ./
77
RUN npm ci
88
COPY web/ .
99
RUN npm run build
1010

1111
# Stage 2 — Runtime (Nginx)
12-
FROM nginx:1.27-alpine
12+
FROM nginx:1.29-alpine
1313
COPY --from=builder /app/dist /usr/share/nginx/html
1414
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
15+
RUN chown -R nginx:nginx /usr/share/nginx/html /var/cache/nginx /var/log/nginx \
16+
&& chmod -R 755 /usr/share/nginx/html
1517

18+
USER nginx
1619
EXPOSE 80

docs/screenshot.png

131 KB
Loading

go.mod

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ go 1.25.0
55
require (
66
github.com/anthropics/anthropic-sdk-go v1.26.0
77
github.com/joho/godotenv v1.5.1
8-
github.com/labstack/echo/v4 v4.15.1
98
github.com/redis/go-redis/v9 v9.18.0
109
go.etcd.io/bbolt v1.4.3
1110
golang.org/x/oauth2 v0.36.0
@@ -25,15 +24,10 @@ require (
2524
github.com/google/uuid v1.6.0 // indirect
2625
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
2726
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
28-
github.com/labstack/gommon v0.4.2 // indirect
29-
github.com/mattn/go-colorable v0.1.14 // indirect
30-
github.com/mattn/go-isatty v0.0.20 // indirect
3127
github.com/tidwall/gjson v1.18.0 // indirect
3228
github.com/tidwall/match v1.1.1 // indirect
3329
github.com/tidwall/pretty v1.2.1 // indirect
3430
github.com/tidwall/sjson v1.2.5 // indirect
35-
github.com/valyala/bytebufferpool v1.0.0 // indirect
36-
github.com/valyala/fasttemplate v1.2.2 // indirect
3731
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
3832
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
3933
go.opentelemetry.io/otel v1.39.0 // indirect
@@ -45,7 +39,6 @@ require (
4539
golang.org/x/sync v0.20.0 // indirect
4640
golang.org/x/sys v0.41.0 // indirect
4741
golang.org/x/text v0.34.0 // indirect
48-
golang.org/x/time v0.15.0 // indirect
4942
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
5043
google.golang.org/grpc v1.79.2 // indirect
5144
google.golang.org/protobuf v1.36.11 // indirect

0 commit comments

Comments
 (0)