Mycellis watches the endpoints your product depends on and tells you the moment one stops breathing normally β health, latency, and uptime, in real time.
Monorepo, two independently deployed apps:
backend/β Spring Boot 4 (Java 21) REST API. Maven project, deployed to Fly.io as a Docker container.frontend/β Vite + React SPA. Deployed to Cloudflare Pages.docs/β internal notes: brand guidelines, architecture decisions, deploy-day reference.grafana/,prometheus.ymlβ local Prometheus/Grafana provisioning, wired up viadocker-compose.yml.docker-compose.ymlβ local Postgres and MailHog (fake SMTP) for backend development, plus Prometheus/Grafana. Does not run the backend itself β that runs directly via Maven.
Backend: Spring Boot 4, Java 21, PostgreSQL (Neon in production, local Docker Postgres in dev), Fly.io hosting, Resend for transactional email, Flyway migrations.
Frontend: Vite, React 19, Tailwind CSS 3, React Router 7, TanStack Query 5, deployed to Cloudflare Pages.
Prerequisites: JDK 21, Docker (for local Postgres/MailHog). Maven itself isn't required β the repo ships a wrapper (mvnw / mvnw.cmd).
git clone <repo-url>
cd mycellis/backend
# Start local Postgres + MailHog β the dev profile's datasource is
# hardcoded to match this compose file's credentials exactly
docker compose up -d postgres mailhog
# Run the app β dev is the default profile, no extra flags needed
./mvnw spring-boot:runThe API comes up on http://localhost:8080. The dev profile boots with zero required environment variables β optional ones (admin seeding, Resend) are documented in backend/.env.example. Spring Boot doesn't auto-load .env files, so export them in your shell or your IDE's run configuration if you want those optional features locally.
Prerequisites: Node 20 LTS or newer (no version pinned in this repo), npm (this repo commits package-lock.json, not a pnpm or yarn lockfile).
cd frontend
cp .env.example .env.local # only needed to point at a non-default API URL
npm install
npm run devDev server runs on http://localhost:5173 and proxies /api and /actuator to localhost:8080 (see vite.config.ts) β you don't need to set VITE_API_BASE_URL locally unless you're pointing at a deployed backend.
npm run build # outputs to frontend/dist
npm run lintflowchart LR
Browser["User's browser"] -->|HTTPS| Pages["Cloudflare Pages<br/>React SPA"]
Pages -->|"api.mycellis.dev<br/>cross-subdomain cookie auth"| Backend["Fly.io backend<br/>Spring Boot"]
Backend -->|SQL| Neon[("Neon Postgres<br/>Frankfurt")]
Backend -->|REST API| Resend["Resend<br/>transactional email"]
Robot["UptimeRobot"] -->|health check| Pages
A stalk (monitored endpoint) gets checked on its own schedule; this is one cycle.
sequenceDiagram
participant Scheduler as Backend scheduler
participant Backend
participant Endpoint as Monitored endpoint
participant DB as Postgres
participant Resend
participant Dashboard
Scheduler->>Backend: trigger pulse for stalk
Backend->>Endpoint: HTTP request
Endpoint-->>Backend: response / timeout
Backend->>DB: record pulse result
alt unhealthy
Backend->>Resend: queue alert email
end
Dashboard->>Backend: GET /api/stalks
Backend->>DB: read latest state
Backend-->>Dashboard: updated stalk state
sequenceDiagram
participant User
participant Frontend as React SPA (mycellis.dev)
participant Backend as Fly.io backend (api.mycellis.dev)
User->>Frontend: submits login form
Frontend->>Backend: POST /api/auth/login
Backend->>Backend: validate credentials, create session
Backend-->>Frontend: Set-Cookie JSESSIONID<br/>Domain=mycellis.dev, SameSite=Lax
Note over Frontend,Backend: cookie shared across mycellis.dev<br/>and api.mycellis.dev subdomains
Frontend->>Backend: GET /api/me (cookie attached)
Frontend->>Backend: GET /api/stalks (cookie attached)
The pulse tick and alert tick are two separate, independently-scheduled loops rather than one pipeline β the code frames this as deliberate: alerting only needs a 60-second cadence at beta scale, and decoupling it from the 5-second pulse tick keeps alert evaluation from ever slowing down the check cycle. Within a single tick, the claim phase (which takes row locks) and the dispatch phase (which makes outbound HTTP calls) are split into separate transactional boundaries specifically so locks are released before any network I/O begins. PulseEngine leans on JDK 21 virtual threads β one per check β to get high concurrency from ordinary blocking calls rather than needing a reactive stack. There's no external message queue anywhere in this flow; "async" here means Spring's in-process event bus plus two small bounded thread pools, not a broker.
flowchart TD
subgraph tick["Pulse tick β every 5s (StalkSchedulerService)"]
Claim["StalkClaimService<br/>SELECT FOR UPDATE, reschedule + jitter<br/>(own transaction, commits first)"]
Engine["PulseEngine<br/>one virtual thread per stalk<br/>blocking RestClient GET"]
Claim --> Engine
end
Engine -->|"publishes PulseCheckedEvent<br/>(in-process, no broker)"| Listener["PulsePersistenceListener<br/>@Async pulseExecutor pool<br/>10-50 threads"]
Listener -->|save| PulseTable[("pulses table")]
Listener -->|"updateMetricsAndTransitionState()"| StalkTable[("stalks table<br/>health index, state")]
subgraph alertTick["Alert tick β every 60s, independent (AlertEngine)"]
Eval["evaluateStalk()<br/>reads recent pulses directly<br/>per active stalk"]
end
PulseTable -.->|reads| Eval
Eval -->|"down 5+ min, or just recovered"| AlertTable[("alerts table<br/>suppression state")]
Eval -->|"if alertsEnabled"| AlertEvent["StalkDownEvent /<br/>StalkRecoveryEvent"]
AlertEvent --> NotifyListener["AlertNotificationListener<br/>@TransactionalEventListener<br/>AFTER_COMMIT"]
NotifyListener --> EmailSvc["EmailService<br/>Resend in prod"]
Dashboard["Dashboard<br/>GET /api/stalks"] -.->|polls| StalkTable
There's no client-side state library beyond TanStack Query β two query keys (['me'], ['dashboard']) are the entire application state, and useDashboardData deliberately merges two REST resources into one query so dependent UI (a stalk's state pill and its sparkline) can never visually desync between renders. Auth gating is composed at the route level (ProtectedRoute β AuthenticatedLayout β SuperAdminRoute) rather than duplicated per page, and the entire API layer is one apiFetch() wrapper with cookie-based sessions β no tokens are ever handled in the frontend.
flowchart TD
subgraph routing["App.tsx β React Router 7"]
Public["Public routes<br/>HomePage, LoginPage, GuidePage,<br/>PublicStatusPage, ..."]
Protected["ProtectedRoute<br/>redirects to /login on 401"]
Layout["AuthenticatedLayout<br/>DashboardHeader + <Outlet/>"]
SuperAdmin["SuperAdminRoute<br/>redirects to /dashboard<br/>if role check fails"]
Protected --> Layout
Layout --> Pages["DashboardPage, StalkDetailPage,<br/>Create/EditStalkPage, SettingsPage"]
Layout --> SuperAdmin
SuperAdmin --> SAPages["SuperAdmin*Page"]
end
Protected -->|useSession| Session
Layout -->|useSession| Session
Pages -->|"useSession + useDashboardData"| QueryLayer
subgraph QueryLayer["lib/hooks β TanStack Query 5 is the only client state"]
Session["useSession()<br/>queryKey ['me']"]
Dashboard["useDashboardData()<br/>queryKey ['dashboard']<br/>stalks + pulses, one query"]
end
Session --> Resources
Dashboard --> Resources
subgraph api["lib/api β one thin wrapper file per resource"]
Resources["stalks Β· pulses Β· me<br/>auth Β· status Β· superAdmin"]
Client["client.ts apiFetch()<br/>credentials: 'include'"]
Resources --> Client
end
Client -->|cookie session| Backend[("api.mycellis.dev")]
Protected -.->|while loading| Loader["MycellisFlowerLoader<br/>components/shared/"]
Pages -.->|renders| Shared["DashboardHeader, CommandPalette,<br/>StalkTable, KpiStrip, Sparkline...<br/>components/dashboard/"]
Backend (from backend/, after committing):
fly deploy -a mycellis-backendFrontend (from frontend/):
npm run build && npx wrangler pages deploy dist --project-name=mycellisSee backend/.env.example and frontend/.env.example for the full list of variables each app reads, with comments on what each one does and which are optional. Production values are set as Fly secrets (fly secrets set ...) and Cloudflare Pages environment variables, respectively β no real values are committed anywhere in this repo.
| Doc | What's in it |
|---|---|
RUNBOOK.md |
Incident response |
COPYRIGHT.md |
Ownership and license terms |
POST_LAUNCH_ROADMAP.md |
Planned post-launch work |
TECH_DEBT.md |
Known shortcuts and their payoff conditions |
