Skip to content

bruuuuuce/panopticon

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

23 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Panopticon

Panopticon logo

Panopticon is a self-contained operational dashboard-as-code tool for AM/support teams, not a generic BI tool: dashboards, panels and data definitions are JSON-defined artifacts, not user-built reports. Data retrieval is provider-agnostic — a panel doesn't know or care whether its data came from SQL, a REST API, or anything else; every data source type is a plugin behind one common interface. No JPA/Hibernate, no server-side chart rendering, no frontend framework/build step — a plain Spring Boot MVC app serving static assets.

Documentation

  • Getting started — prerequisites, local startup, Docker, health checks, and how to run the tests.
  • Creating dashboards — create datasources, data definitions, panels, dashboards, validation, and safe hot reload.
  • This README — architecture, providers, API reference, operational details, and the complete configuration reference.

Screenshots

Dashboard picker Monitor mode (1080p wall display)
Support Operations Overview dashboard Monitor mode rotating dashboards on a 1920x1080 display

Payments Monitoring dashboard

A dashboard can also mix providers freely — provider-showcase renders SQLite and mocked-Jira panels side by side:

Provider Showcase dashboard mixing SQLite and Jira panels

Architecture overview

The backend/frontend split is deliberate and enforced by what each layer is allowed to know:

Backend owns Frontend owns
Dashboard definitions Rendering
Data definition catalog Layout
Datasource registry Chart visualization (ECharts)
Data provider execution (SQL, Jira, ...) Refresh polling
Validation Monitor rotation UX
Runtime state
Result caching

The frontend never talks to a datasource directly, in any form — it only ever calls GET /api/dashboards/{id}/panels/{panelId}/data and renders whatever tabular JSON comes back. Every data definition/datasource/provider concern, and the result cache, live entirely in the backend, behind one interface (DataProvider) the frontend has no visibility into.

Request path, every time a panel asks for data:

Browser (dashboard.js, polling on panel.refresh.intervalSeconds)
  │  GET /api/dashboards/{id}/panels/{panelId}/data
  ▼
DashboardController ─▶ resolves panel.dataRef ─▶ DataEngine.execute(dataId)
                                                      │
                                                      ├─▶ DataRegistry          (dataRef → DataDefinition)
                                                      ├─▶ DataSourceRegistry    (definition.datasource → DataSourceDefinition)
                                                      ├─▶ DataProviderRegistry  (definition.provider → the right DataProvider)
                                                      │        │
                                                      │        ├─ JdbcDataProvider  (SqlGuard, JdbcTemplate, HikariCP)
                                                      │        └─ JiraDataProvider  (MockJiraClient for now)
                                                      │
                                                      ├─▶ DataResultCache  (per-data-id TTL; success + failure both cached)
                                                      └─▶ PanelRuntimeTracker  (success/failure/duration/rowCount, per panel)

DataEngine never branches on provider type — it resolves a definition, its datasource, and the matching provider, then calls provider.execute(context) and gets back the same DataResult shape no matter which provider ran. Adding a new data source type means writing one class that implements DataProvider; nothing else in the request path changes.

Config load path, once at startup (fail-fast: an invalid config aborts startup rather than running half-broken):

config/dashboards/*.json ─┐
config/data/*.json       ─┼─▶ ConfigLoader ─▶ ConfigValidator ─▶ DashboardRegistry / DataRegistry
application.yml (datasources) ─┘                                  (held in memory for the process lifetime)

Data providers

A DataProvider is the plugin contract every data source type implements:

public interface DataProvider {
    String providerType();                    // e.g. "jdbc", "jira"
    boolean supports(DataDefinition definition);
    DataResult execute(DataExecutionContext context);
}

Providers are plain Spring @Component beans — DataProviderRegistry collects every DataProvider bean automatically (constructor-injected as List<DataProvider>) and resolves one by providerType(). There's no dynamic/external plugin loading yet (ServiceLoader, separate JARs, PF4J) — that's a reasonable next step once this in-process contract has proven itself with more than two providers, not something to build ahead of need.

Provider providerType() Datasource shape Status
com.panopticon.data.jdbc.JdbcDataProvider jdbc JDBC URL + driver + dialect Real — H2, SQLite, and (with the right driver/JDBC URL) Oracle
com.panopticon.data.jira.JiraDataProvider jira Jira base URL + auth Mocked — see below

The jdbc provider

Owns everything SQL-shaped: building one HikariCP pool per provider: jdbc datasource at startup (including demo schema/seed scripts), the read-only guard, query timeout/maxRows, and shaping a ResultSet into a DataResult. See SQL read-only guard below.

dialect (generic/h2/oracle/sqlite) is carried per datasource and parsed into a SqlDialect enum, currently informational — row limiting is already portable via Statement.setMaxRows(), so there's no dialect-specific SQL to generate yet. It exists as the extension point for when there is one (e.g. a future pagination clause or identifier-quoting rule).

The jira provider

Supports three operations: issue-search (a table of matching issues), issue-count (a single total), and issue-count-by-field (grouped counts). It's backed entirely by MockJiraClient, an in-memory synthetic issue generator — no real Jira instance or HTTP call is involved yet. This is deliberate: the point of this first pass was proving the provider abstraction with a second, non-JDBC-shaped data source, not building a Jira REST client. MockJiraClient also does not parse JQL — it recognizes exactly one thing, a case-insensitive != done substring, enough to make the bundled demo data definition's "open issues" framing honest without pretending to be a JQL engine. baseUrl/auth on a jira datasource are accepted and carried through config, unused, for a future real implementation to read.

Safety notes

Panopticon executes real queries against real datasources on a schedule, on behalf of anyone who can load a dashboard page — treat data definition authoring with the same care as any other production data access:

  • Use a genuinely read-only database user for every jdbc datasource in panopticon.datasources.*, and set readOnly: true there too — it's applied at the HikariCP pool level (Connection.setReadOnly(true) on every borrowed connection), an extra layer under the SQL guard. (The bundled demo datasources are readOnly: false because they self-seed their own schema/data through that same pool at startup — a real, external datasource should virtually always be true.) The SQL guard is defense in depth, not a substitute for real permissions.
  • Prefer dedicated reporting views over raw tables. A view can pre-join, pre-aggregate, and expose only the columns a dashboard actually needs — so even a guard bypass or an overly broad SELECT * has nothing sensitive to reach.
  • Always set timeoutMs on a data definition (default 10000). A slow or locked query should time out and surface as a panel error, not hang a connection-pool slot indefinitely.
  • Always set maxRows (default 1000). This bounds both the JDBC driver (Statement.setMaxRows) and the row-shaping loop, so a query that unexpectedly matches millions of rows can't OOM the process or blow up a payload to the browser.
  • Avoid expensive queries. Aggregate (GROUP BY, COUNT, SUM) in the database rather than pulling raw rows for the frontend to summarize; add a WHERE clause bounding the time range for anything trend-shaped (see the sample data definitions' DATEADD(...) filters); make sure the columns you filter or join on are indexed. cacheTtlSeconds (default 10) only protects against repeated hits within the TTL window — it does nothing for a query that's expensive on every single execution.

Requirements

  • Java 21
  • Maven 3.9+

Run it

mvn spring-boot:run

or build a jar and run it directly:

mvn clean package
java -jar target/panopticon.jar

The app starts on http://localhost:8080 (override with --server.port=<port> if that's taken). No external database is required — it ships with an in-memory H2 datasource, an in-memory SQLite datasource, and a mocked Jira datasource, all seeded/generated on startup, so every sample dashboard works out of the box:

  • http://localhost:8080/ — dashboard picker + live panels
  • http://localhost:8080/monitor.html — fullscreen rotation between dashboards
  • http://localhost:8080/actuator/health — liveness/readiness; /actuator/metrics/panopticon.data.execution and /actuator/metrics/panopticon.cache expose per-data-id execution timings and cache hit/miss/negative-hit counters (Micrometer — plug in a registry like micrometer-registry-prometheus if you need a scrape endpoint)

Choosing which dashboards load: --dashboards / --data

By default dashboards/data definitions come from config/dashboards and config/data (see Configuration model). Both can be overridden at startup with a comma-separated list of locations, where each entry is either a single .json file or a directory of them:

java -jar target/panopticon.jar \
    --dashboards=/srv/dashboards,extra/one-off-dashboard.json \
    --data=/srv/data

Useful for pointing one binary at different dashboard sets per environment, or mixing a shared directory with a couple of one-off files. Being ordinary Spring properties, they also work as YAML (dashboards: [...]) or env vars.

Recording executed data: --recording

java -jar target/panopticon.jar --recording=recordings

records every fresh data execution (cache hits are replays and are not re-recorded) as one JSON line — data id, datasource, provider, status, timing, full columns/rows payload — to a daily-rolling recordings/panopticon-YYYY-MM-DD.jsonl, written on a background thread so panel serving never waits on disk. Equivalent long form: panopticon.recording.enabled: true + panopticon.recording.directory.

The file is reusable data, not just a log. To load it into a database table:

# once, by hand, against the target database:
#   run src/main/resources/db/recording/recording_table_v1.sql
java -jar target/panopticon.jar \
    --import-recording=recordings/panopticon-2026-07-13.jsonl \
    --import-datasource=demo-h2 \
    --import-table=panopticon_recordings \
    --spring.main.web-application-type=none

This runs as a one-shot CLI (exit 0/1) instead of serving dashboards. The importer deliberately never creates or alters tables: the target table must already exist at the schema version it expects (db/recording/ ships the versioned create script plus an upgrade template), the datasource must be configured read-only: false (writes go through a dedicated plain JDBC connection, never the read-only query pools), and re-importing the same file is idempotent — already-imported lines are skipped via the (source_file, source_line) primary key.

Docker

docker build -t panopticon .
docker run -p 8080:8080 -v ./config:/app/config panopticon

Multi-stage build (Maven → JRE-only runtime); the sample config/ is baked in as a default and can be replaced with a volume mount as above.

Development & testing

mvn test     # unit tests (surefire: *Test classes)
mvn verify   # + the production-like integration suite (failsafe: *IT classes)

mvn verify boots real Spring contexts against temp-file SQLite databases with a background traffic simulator (five writer threads with weighted state machines), and covers realtime data validation, fault injection (missing/ empty/schema-drifted/locked tables), concurrency/cache-collapse stress, and the recording/import round-trip. Fixtures live under it-config/. JaCoCo writes a merged coverage report to target/site/jacoco/index.html; CI (GitHub Actions, .github/workflows/ci.yml) runs mvn verify on every push/PR.

Sample dashboards

Six dashboards ship under config/dashboards/, backed by 30 data definitions under config/data/:

Dashboard What it shows Provider(s)
support-ops Open tickets, avg resolution time, status/priority breakdowns, a 14-day volume trend, recent tickets jdbc (H2)
team-workload Critical backlog, open-ticket distribution by priority, workload by assignee jdbc (H2)
payments-monitoring Revenue today, success rate, payments by status/method, a 14-day volume trend, recent payments jdbc (H2)
db-overview The datasource's own schema — table/row counts and column inventory, read live from information_schema jdbc (H2)
query-performance Ticket SLA view — resolution-time distribution, breaches, avg resolution by priority, slowest resolutions jdbc (H2)
provider-showcase Edge-node status and warehouse inventory (two independent SQLite connections) plus open-issue breakdowns (mocked Jira), side by side jdbc (SQLite ×2) + jira

db-overview is worth calling out on its own: its queries aren't against app tables at all, they're against H2's information_schema — showing the jdbc provider can be pointed at a database's own metadata just as easily as at application data. support-ops and team-workload are also the two dashboards left on the legacy queryRef panel field (see Adding a dashboard), proving that field still works unchanged; every other dashboard uses the current dataRef.

Configuration model

Three things are configured independently, and wired together by reference — a data definition is never duplicated inside a dashboard/panel definition:

What Where Referenced by
Datasources application.yml (panopticon.datasources.*) DataDefinition.datasource
Data definitions config/data/*.json (one per file) PanelDefinition.dataRef
Dashboards config/dashboards/*.json (one per file)

Both config/data and config/dashboards directories are read from disk (not the classpath) at startup — paths are configurable via panopticon.config.dashboards-path / panopticon.config.data-path, relative to the working directory by default, or overridden entirely with the --dashboards / --data startup flags. If the config is invalid the app fails to start (unsupported provider type, unknown datasource, missing SQL for a jdbc definition, missing operation for a jira definition, duplicate ids, rejected SQL, etc.) rather than starting half-broken; the startup log names the problem.

Adding a datasource

Add an entry under panopticon.datasources in application.yml, with a provider matching a registered DataProvider (jdbc or jira today). Only the bundled demo datasources use init-schema/init-data (they populate their own in-memory schema once at startup):

panopticon:
  datasources:
    reporting:
      provider: jdbc
      display-name: "Reporting Warehouse (Oracle)"
      driver-class-name: oracle.jdbc.OracleDriver
      jdbc-url: "jdbc:oracle:thin:@//host:1521/service"
      username: report_ro
      password: "${REPORTING_DB_PASSWORD}"
      dialect: oracle
      read-only: true

    support-jira:
      provider: jira
      display-name: "Support Jira"
      base-url: "https://jira.example.com"
      auth:
        type: bearer
        token: "${JIRA_TOKEN}"

See Safety notes above before pointing a jdbc datasource at a real database. support-jira above would resolve today too — but see The jira provider: it would still be served from the in-memory mock, since there's no real HTTP client behind JiraDataProvider yet.

display-name is optional — every datasource has a connection identity even without it, since it falls back to the config key above (e.g. reporting). It's the label shown wherever a panel's connection is surfaced to a viewer: each dashboard panel shows it in its header (next to the refresh interval), and the Query stats page shows it per query, alongside the technical key. This matters once more than one datasource shares a dialect (e.g. two separate SQLite databases, as local-sqlite and secondary-sqlite demonstrate below) — the panel/query attribution comes from config identity, not from dialect or driver, so getting the datasource name right is what actually tells two same-dialect connections apart. See config/dashboards/provider-showcase.json for a dashboard pulling from all four bundled datasources at once (demo-h2, local-sqlite, secondary-sqlite, demo-jira) as a live example.

Adding a jdbc data definition

One JSON file per data definition under config/data/:

{
  "id": "kpi-open-tickets",
  "name": "Open ticket count",
  "provider": "jdbc",
  "datasource": "demo-h2",
  "sql": "SELECT COUNT(*) AS open_tickets FROM tickets WHERE status <> 'CLOSED'",
  "timeoutMs": 5000,
  "maxRows": 1,
  "cacheTtlSeconds": 10
}

timeoutMs (default 10000), maxRows (default 1000) and cacheTtlSeconds (default 10) are all optional — but see Safety notes for why you should set the first two deliberately rather than rely on the default for anything but a quick trial.

Only SELECT/WITH statements pass the read-only guard — see SQL read-only guard below for exactly what's rejected and why it's not a substitute for real database permissions. Column aliases are lower-cased before reaching the frontend, so panel options can always refer to them in lower case regardless of how the underlying database folds identifiers.

Adding a jira data definition

{
  "id": "jira-open-issues-by-status",
  "provider": "jira",
  "datasource": "demo-jira",
  "operation": "issue-count-by-field",
  "jql": "project = DEMO AND statusCategory != Done",
  "groupBy": "status",
  "timeoutMs": 5000,
  "maxRows": 20
}

operation is required and must be one of issue-search, issue-count, issue-count-by-field. groupBy (default status) only matters for issue-count-by-field; fields (an array — key/summary/status/ priority/assignee/created, default: all of them) only matters for issue-search, letting you narrow which columns a table panel gets, the same way options.columns narrows what a table panel displays. See The jira provider for what's real here and what isn't.

Result caching (all providers)

Results are cached by data id for cacheTtlSeconds (default 10, 0 disables caching for that definition) — this lives in DataEngine/ DataResultCache, above any individual provider, so it applies the same way whether a definition is jdbc, jira, or a future provider. If two panels — even on different dashboards, even backed by different providers in principle — reference the same dataRef, a burst of concurrent refreshes collapses into a single provider execution. generatedAt in the response reflects when the data actually was fetched, so the frontend always shows true data age even on a cache hit.

A failing execution is cached too, for the same TTL as a success. Without this, a datasource/upstream outage would defeat the entire point of caching: every viewer's refresh would re-hit the already-struggling provider instead of being collapsed like a healthy one is. Every request during that window gets the same error immediately, and the next attempt after the TTL expires gets to try again for real.

Adding a dashboard

One JSON file per dashboard under config/dashboards/. grid/gridColumns follow CSS Grid's 1-based row/column numbering; refresh controls how often the frontend re-fetches that panel's data:

{
  "id": "support-ops",
  "title": "Support Operations Overview",
  "accentColor": "#c98500",
  "gridColumns": 12,
  "rotation": { "durationSeconds": 20, "enabled": true },
  "panels": [
    {
      "id": "kpi-open",
      "title": "Open Tickets",
      "type": "stat",
      "dataRef": "kpi-open-tickets",
      "grid": { "row": 1, "col": 1, "rowSpan": 1, "colSpan": 3 },
      "refresh": { "intervalSeconds": 30, "enabled": true },
      "options": { "valueField": "open_tickets", "format": "number" }
    }
  ]
}

accentColor (optional, hex like #3987e5 or #abc, validated at load time) tints the page background with a bottom-to-top gradient wash in that color — panel surfaces are never tinted, so chart/text contrast is unaffected. Give each environment's dashboards a different accent (say, blue for dev, amber for prod) and they become distinguishable at a glance, which is exactly what a wall display cycling through environments needs.

dataRef is the current field name. The older queryRef name (from before the Query → Data rename) is still accepted as a fallback — if dataRef is absent, queryRef is used instead — specifically so existing dashboard JSON doesn't have to change to keep working; support-ops.json/team-workload.json in this repo are still on queryRef, unmodified, as a live example.

options is deliberately a loose map — it's the one place panel-type-specific rendering hints live, since each chart type only needs 2-3 fields:

Panel type Required options keys Optional
stat valueField format (number|decimal), unit
table columns (array; omit to show every column the data definition returns)
bar xField, yField seriesName
line xField, yField seriesName
donut labelField, valueField

/api/config/validate (and startup) reject a panel missing its type's required options keys, or a grid position that's out of bounds for the dashboard's gridColumns.

Fixed alerting thresholds (optional, any panel type)

A panel can declare thresholds: fixed bounds on any field of its data, evaluated by the frontend on every refresh (not stored or computed by the backend — see thresholds.js). A breach turns the panel's status dot warning/critical-colored, and for stat/table panels also colors the KPI value or highlights the offending cell. The bell button in the header (both the dashboard picker and monitor mode) aggregates breaches across every panel of the currently shown dashboard into a badge + dropdown list.

"thresholds": [
  { "field": "low_stock_items", "label": "Low stock SKUs", "warning": 1, "critical": 3, "direction": "above" }
]
  • field: the row field to check — independent of options.valueField/columns, so a table can threshold a column it doesn't even display.
  • warning/critical: at least one required; both optional otherwise.
  • direction: "above" (default, alerts when the value is ≥ the bound — queue length, open tickets) or "below" (alerts when the value is ≤ the bound — stock quantity, uptime percentage).
  • label: optional, shown in the alerts dropdown instead of the raw field name.

provider-showcase.json's kpi-secondary-low-stock/table-secondary-inventory panels are a live example (also exercised by DatasourceIdentificationIT).

Adaptive (statistical) thresholds — config-free, stat panels only

A separate mechanism from fixed thresholds above: the ⚡ Adaptive button in the header (dashboard picker and monitor mode) toggles anomaly detection on/off for every stat panel, app-wide — no JSON, no per-panel opt-in. When on, a panel's current value is compared against the recent mean/standard deviation of its own history (two-sided z-score: ≥2σ warns, ≥3σ is critical) and merges into the exact same breach pipeline fixed thresholds use — dot color, KPI coloring, the alerts bell (marked "⚡ adaptive" there so it's clear it's not a configured rule).

It's bidirectional by design: with no JSON declaring whether high or low is "bad", the signal is simply "this is unusual for this metric lately", in either direction — complementary to a fixed threshold's declared business bound, not a replacement for it (both can be active on the same panel).

The backend (AdaptiveThresholdTracker) records every value a stat panel's options.valueField returns into a bounded per-(dataId, field) recent-window reservoir (capacity 200) and attaches the resulting {sampleCount, mean, stddev} to the panel's DataResult as adaptiveBaseline — always computed, regardless of whether the toggle is on, since it's cheap in-memory bookkeeping and this way flipping the toggle needs no extra round-trip. Below 10 samples there's no baseline yet (too noisy to be meaningful) and the panel simply isn't flagged. Resets on restart, same in-memory tradeoff as the rest of Panopticon's stats (see "Query stats" above).

provider-showcase.json's "Synthetic Load % (demo)" panel (demo-synthetic-load data definition, RAND()-based with cacheTtlSeconds: 0 so every fetch is a genuinely new sample) exists specifically to have real variance to demonstrate this against — most other demo stat panels are deterministic counts against static seed data, which adaptive mode correctly never flags (zero variance ⇒ zero stddev ⇒ no possible breach).

Validate or apply a config edit without restarting the app:

curl -X POST http://localhost:8080/api/config/validate   # dry run: report errors only
curl -X POST http://localhost:8080/api/config/reload     # apply, if (and only if) valid

validate re-reads the effective config locations from disk and reports errors without touching the running app. reload does the same load + validation and, only when fully valid, atomically swaps the live registries (and clears the result cache, since a definition may have changed under the same id) — an invalid config returns 400 with the validation errors and changes nothing, so a bad edit can never take down a running wall display.

REST API

Method Path Purpose
GET /api/dashboards List dashboards (summary: id/title/description/panel count)
GET /api/dashboards/{id} Full dashboard definition (panels, grid, refresh policy)
GET /api/dashboards/{id}/panels/{panelId}/data Execute the panel's data definition, return tabular JSON
GET /api/runtime/panels Last success/failure, duration, row count per panel
GET /api/query-stats Count/avg/min/max/p95/p99 execution duration per data definition, since process start
GET /api/query-stats/{dataId}/plan On-demand EXPLAIN for a jdbc data definition, plus simple full-scan warnings
POST /api/config/validate Dry-run validation of the config as it sits on disk
POST /api/config/reload Validate and, if fully valid, hot-swap the running config (400 + errors otherwise, nothing changes)
GET /actuator/health Liveness/readiness
GET /actuator/metrics/{name} Micrometer meters, incl. panopticon.data.execution and panopticon.cache

This surface is provider-agnostic and unchanged by which provider backs a given panel — a SQLite-backed panel and a Jira-backed panel are indistinguishable to a client of this API. Panel data responses are shaped as:

{
  "columns": [{ "name": "priority", "type": "CHARACTER VARYING" }],
  "rows": [{ "priority": "HIGH" }],
  "generatedAt": "2026-07-10T21:08:33.118Z",
  "executionTimeMs": 7,
  "rowCount": 4,
  "status": "OK",
  "errorMessage": null,
  "datasourceName": "Demo H2 (Ticketing & Payments)"
}

status/errorMessage are populated on every DataResult a provider builds (so DataEngine and the cache can key off them uniformly), but a status: "ERROR" result never actually reaches this endpoint's response body as-is — DataEngine turns it into a thrown exception first, so a failed panel still surfaces as a non-2xx response with an {error, message, timestamp} body, exactly as before this endpoint gained the two new fields. datasourceName is the connection's display-name (or its config key as a fallback, see Adding a datasource) — attached by DataEngine, not by the provider, once a result is known to be a success; a cache hit replays the same value since it's baked into the cached result.

/api/runtime/panels returns one entry per panel that has been requested at least once since the process started:

[
  {
    "dashboardId": "support-ops",
    "panelId": "kpi-open",
    "dataRef": "kpi-open-tickets",
    "lastSuccess": "2026-07-11T14:32:23.587Z",
    "lastFailure": null,
    "lastDurationMs": 7,
    "lastError": null,
    "rowCount": 1
  }
]

lastSuccess/lastFailure are independent and both "sticky": a success doesn't clear a prior failure's lastError, and a failure doesn't clear lastDurationMs/rowCount from the last successful run — so a panel that's currently erroring still shows what it looked like when it last worked, alongside what's wrong now.

Query stats: /query-stats.html

A fixed (not dashboard-as-code) page listing every loaded data definition — across every dashboard — with its real execution history: which connection it runs against (its display-name, with the technical key underneath), last execution time, last duration, records read, total executions/errors, and avg/min/max/p95/p99 duration, each of the last four paired with when it happened. Meant for one question: is the system currently stressing a connected environment? Only real provider executions (cache misses) count — a data definition served entirely from cache shows 0 total executions even under heavy panel traffic, by design (see Result caching).

Stats are in-memory and reset on restart, same tradeoff as the Micrometer metrics above — avg/min/max are exact for the process lifetime; p95/p99 are computed from a bounded reservoir of the most recent 500 samples per data definition, so they're exact for that recent window and approximate over the full lifetime.

Each row has a Plan button that fetches /api/query-stats/{id}/plan on demand — never automatically, so browsing this page never adds load to a datasource by itself. Only jdbc data definitions on H2 or SQLite dialects return a real plan today (the only dialects with an active driver); anything else reports itself unsupported rather than guess at unproven EXPLAIN syntax.

The raw EXPLAIN text (H2's is the rewritten SQL with access comments; SQLite's is a small per-step table) isn't self-explanatory on its own, so it's parsed into structured access paths — one row per table/index touched, its access type (full scan vs. indexed search), and a plain-language detail — shown as the primary view, with a full scan visually flagged; the unparsed plan is still available underneath in a collapsed "Raw plan" section. A query against INFORMATION_SCHEMA (as in the "Database Overview" sample dashboards) legitimately has no table/index access path to extract — H2 doesn't tag catalog reads the same way — and says so rather than showing nothing.

Project structure

src/main/java/com/panopticon/
├── model/       Dashboard/panel/data-definition domain records
├── config/      Datasource + config-path properties, registry wiring
├── loader/      JSON config loading and cross-reference validation
├── registry/    In-memory lookups (dashboards, data definitions, datasources)
├── data/        DataProvider SPI, DataProviderRegistry, DataEngine, DataResultCache
│   ├── jdbc/    JdbcDataProvider, SqlGuard, SqlDialect
│   ├── jira/    JiraDataProvider, MockJiraClient
│   ├── recording/  DataRecorder (JSONL), RecordingImporter + one-shot import runner
│   ├── plan/    ExplainCapable, PlanStep, QueryPlanResult (query stats' on-demand EXPLAIN)
│   └── stats/   QueryExecutionStatsTracker/QueryStatsSnapshot (query stats), AdaptiveThresholdTracker (adaptive thresholds' recent-value history)
├── runtime/     Per-panel refresh-health tracking
└── api/         REST controllers + error handling

src/main/resources/
├── static/      Frontend (index.html, monitor.html, query-stats.html, css/, js/)
└── db/
    ├── mock/      H2 demo schema + seed data (tickets, payments)
    ├── sqlite/    SQLite demo schema + seed data (edge_nodes)
    ├── warehouse/ A second, independent SQLite demo schema + seed data (warehouse_items)
    └── recording/ Versioned DDL for the recording import table (v1 + upgrade template)

config/
├── dashboards/  6 sample dashboards — see "Sample dashboards" above
└── data/        34 sample data definitions backing them

it-config/       Test-only dashboards/data for the production-like integration suite
src/test/        Unit tests (*Test) + integration suite (*IT) — see "Development & testing"
docs/screenshots/  Images embedded in this README

Frontend

Plain HTML/CSS/JS ES modules (no build step) served from static/, using Apache ECharts (vendored locally in static/js/vendor/, no CDN dependency) for bar/line/donut charts and CSS Grid for layout. js/dashboard.js is the shared rendering engine used by both the picker page and monitor mode; each panel fetches and re-renders independently on its own refresh.intervalSeconds, with a status dot (ok/error/pending), a "how often / how recently" label (which also names the panel's connection once its first fetch resolves, e.g. "every 30s · Local SQLite (Edge Nodes) · just now" — truncated with an ellipsis, full name in the tooltip, on narrow panels), a loading state, and an isolated per-panel error state (one panel failing never affects its siblings). None of this changed with the provider refactor — the frontend consumes the same tabular JSON regardless of which provider produced it, and never sees a dataRef/queryRef field at all (that's resolved entirely server-side).

Monitor mode (/monitor.html) rotates through dashboards whose rotation.enabled is true, showing each for its own rotation.durationSeconds, with a dashboard-position indicator ("2 of 6"), a countdown to the next rotation, pause/prev/next controls (also bindable via space/←/→), a real Fullscreen API toggle (F), and a layout that stretches panels to fill the screen edge-to-edge instead of leaving dead space below a fixed row height.

/query-stats.html (see Query stats) is a third fixed page, reachable from the header nav on both the picker and monitor mode, reusing the same table markup/styling as a table-type panel.

The bell button in the header (picker page and monitor mode) opens a dropdown of active threshold breaches — both fixed (see "Fixed alerting thresholds" above) and, if the ⚡ Adaptive toggle next to it is on, statistical anomalies (see "Adaptive (statistical) thresholds" above) — aggregated across every panel of the currently shown dashboard by js/alerts.js. The pure evaluation logic (no DOM) for both lives in js/thresholds.js; the toggle's on/off persistence lives in js/adaptive.js (same localStorage pattern as the theme button). Both are invoked by dashboard.js on every panel refresh.

Theming: a header button on all three pages cycles auto → light → dark (persisted in localStorage). Auto — the default — follows time of day (light 07:00–19:00, dark otherwise), re-checked periodically so a wall display spanning the boundary switches without a reload. Panel polling and monitor rotation pause automatically while the tab is hidden and resume (with an immediate refresh) when it's shown again.

SQL read-only guard

SqlGuard (used only by the jdbc provider) rejects any statement that isn't a single SELECT/WITH, and separately rejects the keywords insert, update, delete, merge, drop, alter, truncate, grant, execute, call, begin, commit, rollback (plus a few more: create, revoke, exec) appearing anywhere in the statement as SQL keywords — string literals and comments are stripped before the keyword scan so they don't cause false positives/negatives.

This is a basic safety layer, not a complete SQL security system. It is a keyword blacklist plus a SELECT/WITH-only allowlist, checked against straightforwardly-formatted SQL — it is not a full parser and could in principle be defeated by SQL it doesn't anticipate. Real deployments must still connect through a genuinely read-only database user, and preferably point queries at dedicated reporting views rather than raw tables, so that even a guard bypass has nothing destructive or sensitive to reach. The guard exists to catch authoring mistakes early and add defense in depth — never as a substitute for real database permissions. See Safety notes.

Known limitations (by design, for this MVP)

  • No auth — this is meant to run inside a trusted network/VPN for an internal team.
  • The Jira provider is mock-only — see The jira provider. No real HTTP call happens yet.
  • No dynamic/external plugin loading — providers are compiled-in Spring beans, not JARs discovered at runtime. See Data providers.

About

Self-contained operational dashboards-as-code for AM and support teams, powered by JSON-defined panels and pluggable data providers.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors