Skip to content

Commit 471e17e

Browse files
notSumit25venkateshsakamuri-labgithub-advanced-security[bot]claude
authored
Dashboard improvements (#51)
Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 3883011 commit 471e17e

14 files changed

Lines changed: 1193 additions & 184 deletions

CLAUDE.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,23 @@ npm run build # Build (dev)
4343
npm run build:production # Build (prod)
4444
```
4545

46-
**Dev credentials**: admin/admin (auth bypass in dev mode)
46+
**Dev credentials**: There is no baked-in admin/admin login — `AuthController.login` requires a
47+
real `User` row matched by **email**, not username, so a fresh database (new Postgres volume)
48+
has no account to log in with at all. `SECURITY_AUTH_ENABLED=false` only bypasses JWT/MCP token
49+
*validation* (`JwtAuthenticationFilter`, `McpTokenAuthenticationFilter`); it does not create a
50+
user or skip the login form. Create the first admin via the bootstrap endpoint, gated by
51+
`SECURITY_ADMIN_BOOTSTRAP_ENABLED=true` + `ADMIN_BOOTSTRAP_SECRET`, and only callable from
52+
localhost:
53+
54+
```bash
55+
curl -X POST http://localhost:8080/api/users/admin/bootstrap \
56+
-H "Content-Type: application/json" \
57+
-H "X-Admin-Bootstrap-Secret: $ADMIN_BOOTSTRAP_SECRET" \
58+
-d '{"email":"admin@localhost","password":"<your-password>"}'
59+
```
60+
61+
Then log in with that **email** (not `admin`) and password. `POST /users/admin/reset` (same
62+
header) replaces the existing admin if you need to rotate the password.
4763

4864
### Database
4965

backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package com.dbaagent.controller;
22

3+
import com.dbaagent.model.SavedDashboard;
34
import com.dbaagent.service.DashboardAgentService;
5+
import com.dbaagent.service.SavedDashboardService;
46
import com.dbaagent.service.security.AccessControlService;
57
import lombok.RequiredArgsConstructor;
68
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.dao.OptimisticLockingFailureException;
710
import org.springframework.http.HttpStatus;
811
import org.springframework.http.MediaType;
912
import org.springframework.http.ResponseEntity;
@@ -18,6 +21,7 @@
1821

1922
import java.io.IOException;
2023
import java.util.Map;
24+
import java.util.UUID;
2125
import java.util.concurrent.Executors;
2226
import java.util.concurrent.ScheduledExecutorService;
2327
import java.util.concurrent.TimeUnit;
@@ -44,6 +48,7 @@ public class DashboardGenerationController {
4448

4549
private final DashboardAgentService dashboardAgentService;
4650
private final AccessControlService accessControlService;
51+
private final SavedDashboardService savedDashboardService;
4752

4853
@PostMapping("/generate")
4954
public ResponseEntity<?> generate(@RequestBody GenerateRequest request) {
@@ -68,13 +73,47 @@ public ResponseEntity<?> generate(@RequestBody GenerateRequest request) {
6873
@PostMapping(value = "/generate/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
6974
public SseEmitter generateStream(@RequestBody GenerateRequest request) {
7075
requireValid(request);
71-
accessControlService.assertCanReadConnectionContent(request.connectionId());
76+
// This path creates/updates a SavedDashboard row (beginGenerationTurn etc.)
77+
// on every call, not just reads — a VIEWER (read-only) must not be able to
78+
// mint or mutate drafts via chat.
79+
accessControlService.assertCanManageConnectionContent(request.connectionId());
80+
SseEmitter emitter = new SseEmitter(600_000L);
81+
82+
// Resolve (or create) the target dashboard and record the user's message
83+
// SYNCHRONOUSLY, before any slow agent work starts — this is what lets a
84+
// reload mid-generation see "still working" (generationStatus=RUNNING)
85+
// instead of nothing at all, even for a brand-new, never-saved dashboard.
86+
// See SavedDashboardService's "Server-owned chat-turn persistence" section.
87+
final SavedDashboard dashboard;
88+
try {
89+
dashboard = savedDashboardService.beginGenerationTurn(
90+
request.dashboardId(), request.connectionId(), request.prompt());
91+
} catch (IllegalArgumentException | IllegalStateException e) {
92+
sendErrorAndComplete(emitter, e.getMessage());
93+
return emitter;
94+
} catch (OptimisticLockingFailureException e) {
95+
// Lost the race to another concurrent submit on the same dashboard —
96+
// same user-facing shape as the "already running" case above.
97+
sendErrorAndComplete(emitter, "A generation is already running for this dashboard.");
98+
return emitter;
99+
}
100+
// The frontend needs this id right away (not just at the end) so a
101+
// brand-new dashboard is addressable — e.g. by a reload — well before
102+
// the potentially multi-minute build finishes.
103+
try {
104+
emitter.send(SseEmitter.event().name("created")
105+
.data(Map.of("dashboardId", dashboard.getId().toString())));
106+
} catch (IOException ignore) {
107+
// Client already gone before we even started streaming — fine, the
108+
// turn is already durably recorded; the work below still runs and
109+
// persists its result regardless of this connection.
110+
}
111+
72112
// Coding a whole dashboard (ground + verify every query + write the HTML) can
73113
// run for minutes. Give it real headroom (10 min) and keep the stream alive
74114
// with a heartbeat — otherwise it emits only 3 step events and the long idle
75115
// gap gets cut by nginx/emitter timeouts before `done`, surfacing to the user
76116
// as "Generation ended unexpectedly".
77-
SseEmitter emitter = new SseEmitter(600_000L);
78117
ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor(r -> {
79118
Thread t = new Thread(r, "dashboard-generate-hb");
80119
t.setDaemon(true);
@@ -106,21 +145,47 @@ public SseEmitter generateStream(@RequestBody GenerateRequest request) {
106145
// `done` event with a real artifact — the FE's done handler always
107146
// appends "Done — built…" and auto-saves. A dedicated `chat` event
108147
// keeps that path from swallowing out-of-context messages.
109-
if (Boolean.TRUE.equals(config.get("chat"))) {
110-
emitter.send(SseEmitter.event().name("chat")
111-
.data(Map.of(
112-
"success", true,
113-
"reply", String.valueOf(config.getOrDefault("reply", "")),
114-
"dashboardConfig", config)));
115-
} else {
116-
emitter.send(SseEmitter.event().name("done")
117-
.data(Map.of("success", true, "dashboardConfig", config)));
148+
boolean chatOnly = Boolean.TRUE.equals(config.get("chat"));
149+
// Persist BEFORE attempting to notify the client — a client that's
150+
// gone by now must never turn an already-successful result into a
151+
// recorded failure (see the catch block below, which only ever
152+
// handles a real dashboardAgentService.generate() failure, not a
153+
// dead SSE connection at delivery time).
154+
try {
155+
if (chatOnly) {
156+
savedDashboardService.appendAgentReply(
157+
dashboard.getId(), String.valueOf(config.getOrDefault("reply", "")));
158+
} else {
159+
savedDashboardService.completeBuildTurn(dashboard.getId(), config);
160+
}
161+
} catch (Exception persistErr) {
162+
log.error("Failed to persist completed dashboard turn {}", dashboard.getId(), persistErr);
163+
}
164+
try {
165+
if (chatOnly) {
166+
emitter.send(SseEmitter.event().name("chat")
167+
.data(Map.of(
168+
"success", true,
169+
"reply", String.valueOf(config.getOrDefault("reply", "")),
170+
"dashboardConfig", config)));
171+
} else {
172+
emitter.send(SseEmitter.event().name("done")
173+
.data(Map.of("success", true, "dashboardConfig", config)));
174+
}
175+
} catch (IOException ignore) {
176+
// Client gone by the time the result was ready — already
177+
// persisted above, so this is a no-op, not a failure.
118178
}
119179
emitter.complete();
120180
} catch (ClientGoneException gone) {
121181
emitter.complete();
122182
} catch (Exception e) {
123183
log.warn("Streamed dashboard generation failed: {}", e.getMessage());
184+
try {
185+
savedDashboardService.appendErrorReply(dashboard.getId(), safe(e));
186+
} catch (Exception persistErr) {
187+
log.error("Failed to persist dashboard generation error {}", dashboard.getId(), persistErr);
188+
}
124189
try {
125190
emitter.send(SseEmitter.event().name("error")
126191
.data(Map.of("success", false, "error", safe(e))));
@@ -134,6 +199,14 @@ public SseEmitter generateStream(@RequestBody GenerateRequest request) {
134199
return emitter;
135200
}
136201

202+
private static void sendErrorAndComplete(SseEmitter emitter, String message) {
203+
try {
204+
emitter.send(SseEmitter.event().name("error")
205+
.data(Map.of("success", false, "error", message == null ? "Request failed" : message)));
206+
} catch (IOException ignore) { }
207+
emitter.complete();
208+
}
209+
137210
private static void requireValid(GenerateRequest request) {
138211
if (request == null || request.connectionId() == null
139212
|| request.prompt() == null || request.prompt().isBlank()) {
@@ -149,5 +222,8 @@ private static final class ClientGoneException extends RuntimeException {
149222
ClientGoneException(Throwable cause) { super(cause); }
150223
}
151224

152-
public record GenerateRequest(String connectionId, String prompt, Object currentConfig) { }
225+
// dashboardId is optional — omitting it (a brand-new, never-saved dashboard)
226+
// always creates a new SavedDashboard row, matching the pre-existing default
227+
// behavior for new dashboards.
228+
public record GenerateRequest(String connectionId, String prompt, Object currentConfig, UUID dashboardId) { }
153229
}

backend/src/main/java/com/dbaagent/controller/SavedDashboardController.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.dbaagent.service.security.AccessControlService;
66
import lombok.extern.slf4j.Slf4j;
77
import org.springframework.beans.factory.annotation.Autowired;
8+
import org.springframework.dao.OptimisticLockingFailureException;
89
import org.springframework.http.HttpStatus;
910
import org.springframework.http.ResponseEntity;
1011
import org.springframework.web.bind.annotation.*;
@@ -25,6 +26,19 @@ public class SavedDashboardController {
2526
@Autowired
2627
private AccessControlService accessControlService;
2728

29+
// Every write method below is load-then-save on a row a background generation
30+
// turn (SavedDashboardService.beginGenerationTurn etc.) may be writing at the
31+
// same time. Without this helper, the loser's raw Hibernate message
32+
// ("Unexpected row count... where id=? and version=?") leaked straight into
33+
// the API response as a 500 instead of a clean, retryable conflict.
34+
private static ResponseEntity<Map<String, Object>> conflict(OptimisticLockingFailureException e) {
35+
log.warn("Dashboard update lost a concurrent-write race: {}", e.getMessage());
36+
Map<String, Object> body = new HashMap<>();
37+
body.put("success", false);
38+
body.put("message", "This dashboard changed elsewhere just now — please retry.");
39+
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
40+
}
41+
2842
/** Publish this dashboard to the web (opt-in, revocable public link). */
2943
@PostMapping("/{id}/share")
3044
public ResponseEntity<Map<String, Object>> enableShare(@PathVariable UUID id) {
@@ -39,6 +53,8 @@ public ResponseEntity<Map<String, Object>> enableShare(@PathVariable UUID id) {
3953
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false, "message", e.getMessage()));
4054
} catch (org.springframework.web.server.ResponseStatusException e) {
4155
throw e;
56+
} catch (OptimisticLockingFailureException e) {
57+
return conflict(e);
4258
} catch (Exception e) {
4359
log.error("Error enabling dashboard share", e);
4460
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
@@ -59,6 +75,8 @@ public ResponseEntity<Map<String, Object>> setSharePassword(@PathVariable UUID i
5975
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false, "message", e.getMessage()));
6076
} catch (org.springframework.web.server.ResponseStatusException e) {
6177
throw e;
78+
} catch (OptimisticLockingFailureException e) {
79+
return conflict(e);
6280
} catch (Exception e) {
6381
log.error("Error setting dashboard share password", e);
6482
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
@@ -79,6 +97,8 @@ public ResponseEntity<Map<String, Object>> disableShare(@PathVariable UUID id) {
7997
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false, "message", e.getMessage()));
8098
} catch (org.springframework.web.server.ResponseStatusException e) {
8199
throw e;
100+
} catch (OptimisticLockingFailureException e) {
101+
return conflict(e);
82102
} catch (Exception e) {
83103
log.error("Error disabling dashboard share", e);
84104
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
@@ -196,6 +216,8 @@ public ResponseEntity<Map<String, Object>> updateDashboard(@PathVariable UUID id
196216
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
197217
} catch (org.springframework.web.server.ResponseStatusException e) {
198218
throw e;
219+
} catch (OptimisticLockingFailureException e) {
220+
return conflict(e);
199221
} catch (Exception e) {
200222
log.error("Error updating saved dashboard", e);
201223
Map<String, Object> errorResponse = new HashMap<>();
@@ -255,6 +277,8 @@ public ResponseEntity<Map<String, Object>> toggleFavorite(@PathVariable UUID id)
255277
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
256278
} catch (org.springframework.web.server.ResponseStatusException e) {
257279
throw e;
280+
} catch (OptimisticLockingFailureException e) {
281+
return conflict(e);
258282
} catch (Exception e) {
259283
log.error("Error toggling favorite", e);
260284
Map<String, Object> errorResponse = new HashMap<>();

backend/src/main/java/com/dbaagent/model/SavedDashboard.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,23 @@ public boolean isSharePasswordSet() {
7777
@Column(length = 255)
7878
private String folder;
7979

80+
// Server-owned "is a generation turn in flight for this dashboard" marker.
81+
// Set to RUNNING the instant a chat submit is accepted (before the slow
82+
// agent work starts) and back to IDLE when it finishes — from the backend
83+
// code path itself, regardless of whether the SSE client that started it
84+
// is still connected. Lets a reload mid-generation distinguish "still
85+
// working" from "answer's ready" without needing to have stayed connected.
86+
// See SavedDashboardService.beginGenerationTurn/appendAgentReply/
87+
// completeBuildTurn/appendErrorReply.
88+
@Column(nullable = false, length = 16)
89+
private String generationStatus = "IDLE";
90+
91+
// When the current RUNNING turn started, so a client can tell a live
92+
// generation from one abandoned by a backend crash (see
93+
// SavedDashboardService.STALE_RUNNING_THRESHOLD).
94+
@Column
95+
private LocalDateTime generationStartedAt;
96+
8097
@CreationTimestamp
8198
@Column(nullable = false, updatable = false)
8299
private LocalDateTime createdAt;
@@ -85,6 +102,16 @@ public boolean isSharePasswordSet() {
85102
@Column(nullable = false)
86103
private LocalDateTime updatedAt;
87104

105+
// Optimistic lock: beginGenerationTurn/appendAgentReply/completeBuildTurn/
106+
// appendErrorReply all do load-then-save on this same row, and two overlapping
107+
// turns (e.g. a slow build finishing after the user already sent a follow-up
108+
// chat) would otherwise silently lose whichever save landed first. Hibernate
109+
// bumps this on every UPDATE and rejects a save whose version is stale with
110+
// OptimisticLockException instead of overwriting.
111+
@Version
112+
@Column(nullable = false)
113+
private Long version = 0L;
114+
88115
// Jackson deserializes create/update bodies via Lombok's all-args constructor
89116
// (Spring's parameter-names module), which bypasses the field defaults and
90117
// leaves these NOT-NULL booleans null when the client omits them. Coerce here
@@ -94,5 +121,6 @@ public boolean isSharePasswordSet() {
94121
void applyBooleanDefaults() {
95122
if (isPublic == null) isPublic = false;
96123
if (isFavorite == null) isFavorite = false;
124+
if (generationStatus == null) generationStatus = "IDLE";
97125
}
98126
}

backend/src/main/java/com/dbaagent/service/DashboardAgentService.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ public Map<String, Object> generate(String connectionId, String prompt, Object c
7474
List<Map<String, Object>> trace = new ArrayList<>();
7575
StepListener l = listener == null ? StepListener.NOOP : listener;
7676

77-
emit(l, trace, "grounding", "Handing off to the DeepSQL agent…");
7877
String username = accessControlService.requireCurrentUsername();
7978
String profile = agentBridgeService.ensureProfileForUser(username, connectionId);
8079
// Fresh session per generation — an isolated coding task, not the user's chat thread.
@@ -87,12 +86,12 @@ public Map<String, Object> generate(String connectionId, String prompt, Object c
8786
// only skip it for something that plainly isn't one (a greeting, a question
8887
// about the tool itself). Answering "hi" by grounding on the schema, writing
8988
// SQL, and self-reviewing an HTML document is where the multi-minute replies
90-
// to trivial messages came from.
89+
// to trivial messages came from. Classify BEFORE emitting any step: a chat-only
90+
// turn should show nothing but the generic "Working" spinner, not a "Handing off
91+
// to the DeepSQL agent" trace that implies a build is underway.
9192
if (isChatOnly(prompt)) {
92-
emit(l, trace, "planning", "Replying…");
9393
AgentChatClient.AgentReply chatReply = agentChatClient.sendAndAwait(sessionId, buildChatTask(prompt));
9494
if (chatReply.ok() && chatReply.text() != null && !chatReply.text().isBlank()) {
95-
emit(l, trace, "done", "Replied");
9695
Map<String, Object> chat = new LinkedHashMap<>();
9796
chat.put("chat", true);
9897
chat.put("reply", chatReply.text().trim());
@@ -106,6 +105,7 @@ public Map<String, Object> generate(String connectionId, String prompt, Object c
106105
// rather than surfacing a failure for what might be a legitimate request.
107106
}
108107

108+
emit(l, trace, "grounding", "Handing off to the DeepSQL agent…");
109109
emit(l, trace, "planning", "Agent is grounding, writing SQL, and coding the dashboard…");
110110
AgentChatClient.AgentReply reply = agentChatClient.sendAndAwait(
111111
sessionId, buildTask(connectionId, prompt, currentConfig));

0 commit comments

Comments
 (0)