Skip to content

Commit f1f5a4c

Browse files
notSumit25claude
andcommitted
feat(dashboards): add refresh/auto-refresh, TV kiosk mode, and AI-evaluated alerts
Add manual + auto-refresh (30s/5m/1h) to the dashboard workspace and viewer, backed by a new reload() method on DashboardArtifact that forces a genuine iframe remount via a key bump. Add TV/kiosk mode to the public share route (?kiosk=1, chrome-less + auto-refresh) with multi-dashboard cycling (?tokens=...&advance=...) that skips password-protected dashboards rather than stalling on an unattended gate; ShareMenu surfaces a ready-made kiosk link once a dashboard is public. Add natural-language dashboard alerts (dashboard_alerts table, V114 migration): a per-dashboard condition evaluated on a schedule by a bounded DeepSQL agent session (fresh, tool-scoped, forced YES/NO + grounded reason), ticked every minute by one db-scheduler recurring task rather than one task per alert. Fired alerts dispatch through new EmailService/WebhookService methods with a per-alert cooldown, and run as whoever created them since there's no ambient "system" identity for a background job. Server-side validation rejects a blank condition on create/update. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 690b057 commit f1f5a4c

17 files changed

Lines changed: 1111 additions & 32 deletions

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ Dashboards are **generated by the embedded DeepSQL Agent acting as a coding agen
141141
- **Sharing**: both share types render a standalone read-only `DashboardViewer` (title + `DashboardArtifact` with an injected `queryFn`). Internal link `/dashboard-view/:id` (auth) uses the authed broker; public link `/share/dashboard/:token` (permitAll) uses `PublicDashboardController` (`GET /api/public/dashboards/{token}` + `/query`), which resolves only while `saved_dashboards.is_public` is true (revoke = flip it) and runs read-only + connection-scoped. `share_token`/`is_public` are set only via `POST|DELETE /api/saved-dashboards/{id}/share` (access-checked), never a general update. `ShareMenu.jsx` drives the UI. The public query path has its own nginx `dashq` limiter.
142142
- **Organization** (search/folders/favorites): `SavedDashboardController`'s search/folder/favorite endpoints existed for a while with no UI consumer. `DashboardsHome.jsx` now wires all of it — a search box (client-side filter over name/description), folder chips derived from `GET /connection/{id}/folders` with a per-card "move to folder" popover (`PUT /saved-dashboards/{id}` with `folder: ""` to clear — `updateDashboard` treats `null` as "field omitted" so blank is the explicit clear signal, same convention as `setSharePassword`), and a favorite star toggle (`POST /{id}/favorite`) with optimistic UI update.
143143
- **Clone**: `POST /saved-dashboards/{id}/clone` (`SavedDashboardService.cloneDashboard`) duplicates a dashboard's config/chat/tags/folder into a fresh row — not shared, not favorited. Exposed as a copy icon on each `DashboardsHome.jsx` card.
144-
- **Version history**: every real overwrite of `dashboardConfig` (agent build via `completeBuildTurn`, manual Source-tab edit via `updateDashboard`, or a restore) snapshots the *previous* config into `dashboard_versions` (`V113__create_dashboard_versions.sql`) before overwriting, tagged with a trigger (`AGENT_BUILD`/`MANUAL_EDIT`/`RESTORE`) — capped at 50 snapshots per dashboard, oldest pruned first. `GET /{id}/versions` lists them newest-first; `POST /{id}/versions/{versionId}/restore` swaps a snapshot back in as current (itself snapshotting whatever was live, so a restore is undoable too). `DashboardWorkspace.jsx`'s canvas toolbar has a History panel (reusing the Queries panel's layout) listing versions with a Restore button, disabled while a build is in flight to avoid racing the agent's own write.
144+
- **Version history**: every real overwrite of `dashboardConfig` (agent build via `completeBuildTurn`, manual Source-tab edit via `updateDashboard`, or a restore) snapshots the *previous* config into `dashboard_versions` (`V113__create_dashboard_versions.sql`) before overwriting, tagged with a trigger (`AGENT_BUILD`/`MANUAL_EDIT`/`RESTORE`) — capped at 50 snapshots per dashboard, oldest pruned first. `GET /{id}/versions` lists them newest-first; `POST /{id}/versions/{versionId}/restore` swaps a snapshot back in as current (itself snapshotting whatever was live, so a restore is undoable too) and **dedupes**: after a restore, the restored row plus any other row with byte-identical `dashboard_config` are deleted, since that content is now "Current," not history — otherwise a restore-edit-restore cycle piles up an alternating chain of duplicate snapshots. `DashboardWorkspace.jsx`'s History panel shows a lightweight diff summary per entry (title/widget-count/size delta computed client-side, not a real line diff — the agent rewrites large chunks even for small logical changes) plus a Preview modal that renders that version's HTML live via `DashboardArtifact`.
145+
- **Refresh**: `DashboardArtifact`'s `useImperativeHandle` exposes `reload()`, which bumps an internal `reloadEpoch` state used as the `<iframe>`'s `key` — forcing a genuine remount (and re-running every widget's `deepsql.query()` call) even when `html` is referentially unchanged, which changing `html`/`srcDoc` alone can't guarantee. `DashboardWorkspace.jsx`'s canvas toolbar has a manual Refresh button plus an auto-refresh interval dropdown (Off/30s/5m/1h) that calls it on a timer, paused while a build is in flight (a completing build already replaces the iframe). `DashboardViewer.jsx` (both share surfaces) takes the same `autoRefreshMs` optionally, plus `hideChrome` for kiosk mode.
146+
- **TV/kiosk mode**: `PublicDashboardPage.jsx` reads `?kiosk=1&refresh=<seconds>` (chrome-less + auto-refresh, floor 10s) and `?tokens=tokA,tokB&advance=<seconds>` (cycles through multiple public share tokens, dwelling `advance` seconds each — the route's own `:token` is always the first slide). A password-protected dashboard mid-cycle is skipped (there's no one there to type a password) rather than parking the whole kiosk on a gate. `ShareMenu.jsx` surfaces a ready-made kiosk link (`?kiosk=1&refresh=60`) once a dashboard is public and unprotected.
147+
- **Alerts**: `dashboard_alerts` (`V114__create_dashboard_alerts.sql`) holds a natural-language condition per dashboard (e.g. "alert if the error rate exceeds 5% in the last hour"), evaluated on a schedule by `DashboardAlertService.evaluate()` — a **bounded agent session** (fresh `ensureSession`, no tools beyond `execute_sql`/schema lookups, a short task prompt asking for exactly `YES`/`NO` + a one-sentence reason grounded in a real query result) reusing the same agent plumbing as dashboard generation, just for a one-line answer instead of a whole HTML document. `DashboardAlertTaskConfig` registers one db-scheduler recurring task (`dashboard-alert-tick`, every minute) that evaluates whichever alerts are actually due per `DashboardAlertRepository.findDue` (each alert has its own `checkIntervalMinutes`) rather than one scheduled task per alert. A fired alert dispatches through `EmailService.sendDashboardAlert`/`WebhookService.sendDashboardAlert` (new methods, same pattern as the existing growth/slow-query alert methods) gated by a per-alert `cooldownMinutes` so a condition that stays true doesn't re-fire every tick. The alert runs **as whoever created it** (`createdByUsername`, captured at creation time) — there's no ambient "system" identity for a background job, and running every alert as an arbitrary admin would let one user's alert read data through someone else's access grant. `DashboardAlertController` is the CRUD surface (`/saved-dashboards/{id}/alerts`); `DashboardWorkspace.jsx`'s toolbar has an Alerts panel (composer + per-alert enable/disable/delete, last-check verdict shown inline).
145148

146149
## LLM Providers
147150

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.dbaagent.controller;
2+
3+
import com.dbaagent.model.DashboardAlert;
4+
import com.dbaagent.model.SavedDashboard;
5+
import com.dbaagent.service.DashboardAlertService;
6+
import com.dbaagent.service.SavedDashboardService;
7+
import com.dbaagent.service.security.AccessControlService;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.http.HttpStatus;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.web.bind.annotation.*;
13+
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.UUID;
17+
18+
/** CRUD for per-dashboard natural-language alerts — see DashboardAlertService for evaluation. */
19+
@RestController
20+
@RequestMapping("/saved-dashboards/{dashboardId}/alerts")
21+
@RequiredArgsConstructor
22+
@Slf4j
23+
public class DashboardAlertController {
24+
25+
private final DashboardAlertService alertService;
26+
private final SavedDashboardService savedDashboardService;
27+
private final AccessControlService accessControlService;
28+
29+
@PostMapping
30+
public ResponseEntity<Map<String, Object>> create(@PathVariable UUID dashboardId, @RequestBody DashboardAlert draft) {
31+
try {
32+
SavedDashboard dashboard = requireDashboard(dashboardId);
33+
accessControlService.assertCanManageConnectionContent(dashboard.getConnectionId());
34+
String username = accessControlService.requireCurrentUsername();
35+
DashboardAlert created = alertService.createAlert(dashboardId, username, draft);
36+
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("success", true, "alert", created));
37+
} catch (IllegalArgumentException e) {
38+
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false, "message", e.getMessage()));
39+
} catch (org.springframework.web.server.ResponseStatusException e) {
40+
throw e;
41+
} catch (Exception e) {
42+
log.error("Error creating dashboard alert", e);
43+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("success", false, "message", "Failed to create alert"));
44+
}
45+
}
46+
47+
@GetMapping
48+
public ResponseEntity<Map<String, Object>> list(@PathVariable UUID dashboardId) {
49+
try {
50+
SavedDashboard dashboard = requireDashboard(dashboardId);
51+
accessControlService.assertCanReadConnectionContent(dashboard.getConnectionId());
52+
List<DashboardAlert> alerts = alertService.getAlertsForDashboard(dashboardId);
53+
return ResponseEntity.ok(Map.of("success", true, "alerts", alerts));
54+
} catch (IllegalArgumentException e) {
55+
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false, "message", e.getMessage()));
56+
} catch (org.springframework.web.server.ResponseStatusException e) {
57+
throw e;
58+
} catch (Exception e) {
59+
log.error("Error listing dashboard alerts", e);
60+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("success", false, "message", "Failed to fetch alerts"));
61+
}
62+
}
63+
64+
@PutMapping("/{alertId}")
65+
public ResponseEntity<Map<String, Object>> update(@PathVariable UUID dashboardId, @PathVariable UUID alertId, @RequestBody DashboardAlert updates) {
66+
try {
67+
SavedDashboard dashboard = requireDashboard(dashboardId);
68+
accessControlService.assertCanManageConnectionContent(dashboard.getConnectionId());
69+
DashboardAlert updated = alertService.updateAlert(alertId, updates);
70+
return ResponseEntity.ok(Map.of("success", true, "alert", updated));
71+
} catch (IllegalArgumentException e) {
72+
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false, "message", e.getMessage()));
73+
} catch (org.springframework.web.server.ResponseStatusException e) {
74+
throw e;
75+
} catch (Exception e) {
76+
log.error("Error updating dashboard alert", e);
77+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("success", false, "message", "Failed to update alert"));
78+
}
79+
}
80+
81+
@DeleteMapping("/{alertId}")
82+
public ResponseEntity<Map<String, Object>> delete(@PathVariable UUID dashboardId, @PathVariable UUID alertId) {
83+
try {
84+
SavedDashboard dashboard = requireDashboard(dashboardId);
85+
accessControlService.assertCanManageConnectionContent(dashboard.getConnectionId());
86+
alertService.deleteAlert(alertId);
87+
return ResponseEntity.ok(Map.of("success", true));
88+
} catch (org.springframework.web.server.ResponseStatusException e) {
89+
throw e;
90+
} catch (Exception e) {
91+
log.error("Error deleting dashboard alert", e);
92+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("success", false, "message", "Failed to delete alert"));
93+
}
94+
}
95+
96+
private SavedDashboard requireDashboard(UUID dashboardId) {
97+
return savedDashboardService.getDashboardById(dashboardId)
98+
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
99+
}
100+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.dbaagent.model;
2+
3+
import jakarta.persistence.*;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Data;
6+
import lombok.NoArgsConstructor;
7+
import org.hibernate.annotations.CreationTimestamp;
8+
import org.hibernate.annotations.UpdateTimestamp;
9+
10+
import java.time.LocalDateTime;
11+
import java.util.UUID;
12+
13+
@Entity
14+
@Table(name = "dashboard_alerts", indexes = {
15+
@Index(name = "idx_dashboard_alerts_dashboard_id", columnList = "dashboardId"),
16+
@Index(name = "idx_dashboard_alerts_due", columnList = "isEnabled, lastCheckedAt")
17+
})
18+
@Data
19+
@NoArgsConstructor
20+
@AllArgsConstructor
21+
public class DashboardAlert {
22+
23+
@Id
24+
@GeneratedValue(strategy = GenerationType.UUID)
25+
private UUID id;
26+
27+
@Column(nullable = false)
28+
private UUID dashboardId;
29+
30+
@Column(nullable = false)
31+
private String connectionId;
32+
33+
// The alert runs as whoever created it (their agent profile/session) — there is
34+
// no ambient "system" identity to fall back to for a background job, and running
35+
// it as an arbitrary admin would let anyone's alert read data through someone
36+
// else's access grant.
37+
@Column(nullable = false)
38+
private String createdByUsername;
39+
40+
// Natural-language threshold, e.g. "alert if error_rate exceeds 5% in the last hour".
41+
@Column(nullable = false, columnDefinition = "TEXT")
42+
private String conditionText;
43+
44+
// Comma-separated subset of: in-app, email, webhook.
45+
@Column(nullable = false, length = 255)
46+
private String channels = "in-app";
47+
48+
@Column(length = 1000)
49+
private String emailRecipients;
50+
51+
@Column(length = 1000)
52+
private String webhookUrl;
53+
54+
@Column(nullable = false)
55+
private Boolean isEnabled = true;
56+
57+
@Column(nullable = false)
58+
private Integer checkIntervalMinutes = 15;
59+
60+
// Minimum time between two firings, independent of check interval — a condition
61+
// that stays true for hours should page once, not every 15 minutes.
62+
@Column(nullable = false)
63+
private Integer cooldownMinutes = 60;
64+
65+
@Column
66+
private LocalDateTime lastCheckedAt;
67+
68+
@Column
69+
private LocalDateTime lastFiredAt;
70+
71+
@Column(length = 16)
72+
private String lastVerdict;
73+
74+
@Column(columnDefinition = "TEXT")
75+
private String lastReason;
76+
77+
@Column(columnDefinition = "TEXT")
78+
private String lastError;
79+
80+
@CreationTimestamp
81+
@Column(nullable = false, updatable = false)
82+
private LocalDateTime createdAt;
83+
84+
@UpdateTimestamp
85+
@Column(nullable = false)
86+
private LocalDateTime updatedAt;
87+
88+
@PrePersist
89+
@PreUpdate
90+
void applyDefaults() {
91+
if (isEnabled == null) isEnabled = true;
92+
if (checkIntervalMinutes == null) checkIntervalMinutes = 15;
93+
if (cooldownMinutes == null) cooldownMinutes = 60;
94+
if (channels == null || channels.isBlank()) channels = "in-app";
95+
}
96+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.dbaagent.repository;
2+
3+
import com.dbaagent.model.DashboardAlert;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Query;
6+
import org.springframework.stereotype.Repository;
7+
8+
import java.time.LocalDateTime;
9+
import java.util.List;
10+
import java.util.UUID;
11+
12+
@Repository
13+
public interface DashboardAlertRepository extends JpaRepository<DashboardAlert, UUID> {
14+
15+
List<DashboardAlert> findByDashboardIdOrderByCreatedAtDesc(UUID dashboardId);
16+
17+
// Due = enabled AND (never checked OR its own interval has elapsed since the last
18+
// check). Computed in SQL rather than pulled into Java so a growing alert count
19+
// never means pulling every row into memory just to filter most of them out.
20+
// The now::timestamp cast is required — without it, Postgres can't resolve
21+
// whether the parameter or the (timestamp - interval) expression should drive
22+
// the comparison's type and rejects the query with "operator does not exist:
23+
// timestamp without time zone <= interval".
24+
@Query(value = "SELECT * FROM dashboard_alerts WHERE is_enabled = true "
25+
+ "AND (last_checked_at IS NULL OR last_checked_at <= CAST(:now AS timestamp) - (check_interval_minutes * INTERVAL '1 minute'))",
26+
nativeQuery = true)
27+
List<DashboardAlert> findDue(LocalDateTime now);
28+
}

0 commit comments

Comments
 (0)