Skip to content
Mayorman07Public

Latest commit

Β 

History

242 Commits

Folders and files

Repository files navigation

Mycellis

Mycellis

Java 21 Spring Boot 4 React 19 TypeScript PostgreSQL / Neon Fly.io Proprietary license

What it is

Mycellis watches the endpoints your product depends on and tells you the moment one stops breathing normally β€” health, latency, and uptime, in real time.

Mycellis dashboard

Project structure

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 via docker-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.

Tech stack

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.

Quick Start

Backend

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:run

The 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.

Frontend

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 dev

Dev 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 lint

Architecture

System overview

flowchart 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
Loading

Pulse cycle

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
Loading

Auth flow

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)
Loading

Backend internals

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
Loading

Frontend internals

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 + &lt;Outlet/&gt;"]
        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/"]
Loading

Deployment

Backend (from backend/, after committing):

fly deploy -a mycellis-backend

Frontend (from frontend/):

npm run build && npx wrangler pages deploy dist --project-name=mycellis

Environment variables

See 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.

Documentation Index

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