I used Cursor to generate most of Sentinel’s initial dashboard UI. Within a few minutes, I had a professional-looking Next.js app with uptime tables, latency charts, incident feeds, and typed Prisma integrations. It worked, it looked polished… and then the data started growing.
Live dashboard: Sentinel
Source: GitHub
AI Is Incredible at Getting You to Version One
Honestly, the AI did an incredible job. If I had built all of this manually, it would have taken significantly longer. UI work has never been my primary interest, and AI let me skip a lot of the friction involved in scaffolding an application from scratch.
Within a few minutes, I had uptime tables, latency charts, incident feeds, summary cards, responsive layouts, dark mode styling, loading states, and typed Prisma integrations.
That’s where modern AI tools genuinely shine: boilerplate, component generation, layout structure, and wiring together standard application flows. For getting from “blank repository” to “working product,” the productivity jump is hard to overstate.
The generated code wasn’t bad. The dashboard looked polished. The Prisma queries were syntactically correct. The app worked.
At first.
Sentinel Became a Real Systems Problem
Sentinel started as a straightforward observability project:
- A checker service performs scheduled HTTP health checks
- Results are written into PostgreSQL through Prisma
- A Next.js dashboard visualizes uptime, incidents, and latency trends
Observability systems have a very different growth pattern than traditional CRUD applications. Every query pattern that feels harmless during development compounds over time.
The data is append-only, time-series oriented, and constantly accumulating. Even with only seven monitored targets, Sentinel was already generating roughly 20,000 new rows per day.
Every page load fetched fresh operational data directly from Postgres: current status, latency trends, uptime percentages, active incidents, and historical charts. The application wasn’t just rendering UI anymore. It was continuously aggregating operational history across growing time windows.
Nothing initially looked broken. The queries worked. The charts rendered. The UI felt fast enough during early development. But Sentinel became the first project where I really felt the distinction between building an application and engineering a system that could keep scaling as the workload changed.
The Dashboard Worked, But the Queries Didn’t Scale
The first version was functionally correct. Uptime charts rendered, incidents appeared, latency graphs updated, and service status cards reflected real data. From the outside, it looked production-ready.
This wasn’t broken AI code. It was plausible code doing the wrong kind of work.
The original pattern was consistent everywhere: fetch data with Prisma, load rows into Node.js, aggregate in TypeScript, render charts. One of the clearest examples was how the dashboard loaded “latest status” for monitored services:
const checks = await prisma.healthCheck.findMany({
orderBy: { checkedAt: "desc" },
take: 5000,
});
const latestByTarget = new Map();
// ... walk rows in JS to keep newest per target
Technically correct. Architecturally expensive. The database returned thousands of rows so Node.js could throw most of them away to find the newest record per target.
The same pattern showed up across uptime calculations, daily chart bucketing, and latency percentiles, all aggregated in application code after large result sets had already left Postgres. Individually, none of these queries looked alarming. That’s what makes this kind of problem tricky. AI tends to generate code that is locally reasonable, but systems performance problems are usually global, not local.
The overview page was the clearest example. Multiple widgets fetched overlapping 30-day datasets simultaneously. Some loaders even called other loaders internally, meaning the same time window could be scanned multiple times during a single page render.
The core issue wasn’t a single bad query. It was the cumulative effect of reasonable decisions stacking together, plus an unanswered systems design question: where should the computation actually happen?
PostgreSQL is optimized for aggregation, grouping, and time-series rollups. Node.js is not. But the original implementation treated Postgres mostly as storage while application code handled the analytical work.
The biggest realization was that most dashboard components didn’t need raw rows. They needed summaries:
- The uptime chart needed daily percentages and time buckets, not hundreds of thousands of individual checks
- The services table needed one latest row per target, not thousands of recent rows
- The summary cards needed averages, counts, and rollups
That distinction changed how I approached the data layer. Instead of querying large windows and deriving summaries in Node.js, the optimized architecture pushed aggregation into Postgres and returned only the minimal datasets the UI actually needed.
AI is very good at generating applications that work. It is not automatically good at generating systems that scale.
The Optimizations That Changed Everything
Once I understood the actual problem, the fixes became straightforward. The goal wasn’t to “make Prisma faster.” It was to reduce how much work the system was doing.
1. Matching Indexes to Read Patterns
Sentinel’s workload is heavily time-oriented. I added indexes aligned with how the dashboard actually reads data:
checkedAt(targetId, checkedAt DESC)
The second index mattered most. So much of the UI depends on answering one question efficiently: “What is the latest check for this target?“
2. Replacing 5,000-Row Fetches with DISTINCT ON
Postgres already has a native solution for latest-per-group lookups:
SELECT DISTINCT ON ("targetId")
"targetId",
status,
"latencyMs",
"checkedAt"
FROM "HealthCheck"
ORDER BY "targetId", "checkedAt" DESC;
At seven targets, the query dropped from thousands of rows returned to roughly seven.
3. Moving Aggregation into PostgreSQL
Uptime percentages became grouped count queries instead of full row scans:
await prisma.healthCheck.groupBy({
by: ["targetId", "status"],
_count: { _all: true },
});
The dashboard stopped processing huge raw datasets and started consuming compact analytical summaries instead.
4. Using date_trunc for Time-Series Rollups
Chart bucketing moved into SQL:
SELECT
date_trunc('day', "checkedAt") AS day,
COUNT(*) FILTER (WHERE status = 'UP') AS up_count,
COUNT(*) AS total
FROM "HealthCheck"
GROUP BY 1;
Chart datasets dropped from massive raw result sets to roughly 30 rows for 30-day uptime and 60 buckets for 60-minute latency. The dashboard no longer scaled based on total historical row count. It scaled based on chart resolution.
5. Consolidating the Overview Page
The final fix was architectural. I replaced independent widget loaders with a single coordinated overview loader that fetched shared datasets once, ran aggregate queries in parallel, derived view models centrally, and reused results across widgets.
By the end of the refactor, the dashboard became a thin analytical layer on top of PostgreSQL: aggregate in SQL, return minimal datasets, compose lightweight view models, render efficiently.
The Moment the Optimizations Went Live
The most interesting part of the refactor wasn’t the SQL itself. It was watching the system behavior change immediately after deployment. After deploying the query and aggregation changes, the impact showed up almost instantly in the telemetry graphs.
Average latency across the applications hosted on my VPS dropped by more than 60% within minutes of deployment.
The graph was honestly a little surreal to watch in real time. The system stabilized almost instantly.
What made this especially interesting is that nothing about the infrastructure itself changed:
- Same VPS
- Same Docker containers
- Same applications
- Same network
- Same traffic
The improvement came entirely from reducing unnecessary computation and database load. That was an important reminder that performance bottlenecks are often architectural, not hardware-related.
AI Accelerates Implementation, Not Architecture
Working on Sentinel reinforced something I think a lot of AI-assisted projects are running into.
AI optimizes for plausibility, not operational understanding. It can generate code that looks architecturally correct because it has seen thousands of similar implementations. But it doesn’t reason about workload growth, query cost, memory pressure, or how overlapping dashboard widgets compound under real data volume.
That’s usually fine for version one. It’s not fine for observability systems where data continuously accumulates and charts repeatedly aggregate historical windows.
The bottleneck in software development is shifting. Writing syntax matters less. What matters more is identifying architectural problems, understanding where computation belongs, recognizing inefficient data flow, and making tradeoff decisions as workloads evolve.
AI helped me scaffold the dashboard, generate layouts, wire components, and accelerate iteration. Without Cursor, Sentinel would have taken significantly longer to get off the ground. That acceleration is real.
But once the application started behaving like an actual observability system instead of a prototype, the important questions changed:
- “Why is this query scanning so much data?”
- “Why are multiple widgets loading the same window?”
- “Why is Node.js doing analytical work instead of Postgres?”
- “How does this architecture behave six months from now?”
Those are engineering questions. And I think that’s the future: not AI versus engineers, but engineers who understand systems and use AI as leverage to move faster through version one while still doing the work that makes version one survive real conditions.
AI built the dashboard faster than I could have manually. Understanding Postgres, query design, and systems architecture is what made it viable as the data started growing.