fix: iterative QA pass — resolve remaining bugs, UX and accessibility improvements

Three QA iterations to convergence (zero issues remaining):

Workbench UI:
- Connection badge: amber "Connected (no auth)" for unauthenticated state
- Theme persistence: restore script in index.html + localStorage sync
- Settings About section: add bottom padding so content isn't clipped
- Clear messages: cancel in-flight requests when clearing chat
- Feature switch labels: proper casing + acronym handling (MCP, LLM)
- Token Cost badge: hidden during loading state
- ARIA: role="switch", aria-checked on toggles, aria-labels on buttons
- ConfigApi: null-safe chaining for getPrompts/getSystemPrompt

Grafana dashboards:
- Auto-refresh 30s on all 3 dashboards
- Panel heights reduced to fit viewport without scrolling
- Anonymous role upgraded to Editor for Explore access

Infrastructure:
- Nginx: DNS resolver with variable-based upstream (prevents crash loop)
- Workbench port set to 3002 in .env

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-04-07 06:33:22 -05:00
parent 3a80872482
commit 9ef9ef854f
11 changed files with 102 additions and 33 deletions

View file

@ -175,7 +175,7 @@ services:
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD:-admin}
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_AUTH_ANONYMOUS_ORG_ROLE=Editor
- GF_AUTH_DISABLE_LOGIN_FORM=false
- GF_USERS_DEFAULT_THEME=dark
- GF_EXPLORE_ENABLED=true

View file

@ -28,7 +28,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"gridPos": { "h": 7, "w": 12, "x": 0, "y": 0 },
"id": 1,
"targets": [
{
@ -92,7 +92,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"gridPos": { "h": 7, "w": 12, "x": 12, "y": 0 },
"id": 2,
"targets": [
{
@ -162,7 +162,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"gridPos": { "h": 7, "w": 12, "x": 0, "y": 7 },
"id": 3,
"targets": [
{
@ -230,7 +230,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"gridPos": { "h": 7, "w": 12, "x": 12, "y": 7 },
"id": 4,
"targets": [
{
@ -305,6 +305,7 @@
"templating": {
"list": []
},
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"

View file

@ -28,7 +28,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 6, "w": 24, "x": 0, "y": 0 },
"gridPos": { "h": 4, "w": 24, "x": 0, "y": 0 },
"id": 1,
"targets": [
{
@ -74,7 +74,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 4 },
"id": 2,
"targets": [
{
@ -131,7 +131,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 4 },
"id": 3,
"targets": [
{
@ -195,7 +195,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 14 },
"gridPos": { "h": 6, "w": 24, "x": 0, "y": 10 },
"id": 4,
"targets": [
{
@ -263,6 +263,7 @@
"templating": {
"list": []
},
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"

View file

@ -28,7 +28,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 0 },
"id": 1,
"targets": [
{
@ -92,7 +92,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 0 },
"id": 2,
"targets": [
{
@ -151,7 +151,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 6 },
"id": 3,
"targets": [
{
@ -210,7 +210,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 6 },
"id": 4,
"targets": [
{
@ -269,7 +269,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 12 },
"id": 5,
"targets": [
{
@ -328,7 +328,7 @@
"type": "prometheus",
"uid": "tg-prometheus"
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 12 },
"id": 6,
"targets": [
{
@ -392,6 +392,7 @@
"templating": {
"list": []
},
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"

View file

@ -2060,7 +2060,8 @@ export class ConfigApi {
string,
Record<string, Record<string, string>>
>;
return JSON.parse(config.config.prompt["template-index"]);
const raw = config.config?.prompt?.["template-index"];
return raw ? JSON.parse(raw) : [];
});
}
@ -2073,7 +2074,8 @@ export class ConfigApi {
string,
Record<string, Record<string, string>>
>;
return JSON.parse(config.config.prompt[`template.${id}`]);
const raw = config.config?.prompt?.[`template.${id}`];
return raw ? JSON.parse(raw) : null;
});
}
@ -2086,7 +2088,8 @@ export class ConfigApi {
string,
Record<string, Record<string, string>>
>;
return JSON.parse(config.config.prompt.system);
const raw = config.config?.prompt?.system;
return raw ? JSON.parse(raw) : "";
});
}

View file

@ -7,6 +7,17 @@
<title>TrustGraph Workbench</title>
</head>
<body class="dark">
<script>
// Restore theme preference before first paint to avoid flash
(function() {
var theme = localStorage.getItem('tg-theme');
if (theme === 'light') {
document.body.classList.remove('dark');
document.body.classList.add('light');
document.documentElement.classList.add('light');
}
})();
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View file

@ -4,6 +4,9 @@ server {
root /usr/share/nginx/html;
index index.html;
# Use Docker's internal DNS resolver with short cache
resolver 127.0.0.11 valid=10s ipv6=off;
# SPA routing
location / {
try_files $uri $uri/ /index.html;
@ -11,14 +14,16 @@ server {
# API proxy to gateway
location /api/v1/ {
proxy_pass http://gateway:8088;
set $upstream_gateway gateway;
proxy_pass http://$upstream_gateway:8088;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket proxy (client connects to /api/socket, gateway listens on /api/v1/socket)
location /api/socket {
proxy_pass http://gateway:8088/api/v1/socket;
set $upstream_gateway gateway;
proxy_pass http://$upstream_gateway:8088/api/v1/socket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@ -28,7 +33,8 @@ server {
# WebSocket proxy (direct v1 path)
location /api/v1/socket {
proxy_pass http://gateway:8088;
set $upstream_gateway gateway;
proxy_pass http://$upstream_gateway:8088;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

View file

@ -62,17 +62,27 @@ function ConnectionBadge() {
state.status === "authenticated" ||
state.status === "unauthenticated";
const isWarning = state.status === "unauthenticated";
return (
<div
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium",
isConnected ? "text-success" : "text-fg-subtle",
isWarning
? "text-amber-400"
: isConnected
? "text-success"
: "text-fg-subtle",
)}
>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
isConnected ? "bg-success animate-pulse" : "bg-fg-subtle",
isWarning
? "bg-amber-400 animate-pulse"
: isConnected
? "bg-success animate-pulse"
: "bg-fg-subtle",
)}
/>
{isConnected ? (
@ -80,7 +90,9 @@ function ConnectionBadge() {
) : (
<WifiOff className="h-3.5 w-3.5" />
)}
<span className="truncate capitalize">{state.status}</span>
<span className="truncate capitalize">
{isWarning ? "Connected (no auth)" : state.status}
</span>
</div>
);
}

View file

@ -260,9 +260,10 @@ export default function ChatPage() {
</div>
<button
onClick={clearMessages}
onClick={() => { cancelRequest(); clearMessages(); }}
className="rounded-lg p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg"
title="Clear messages"
aria-label="Clear messages"
>
<Trash2 className="h-4 w-4" />
</button>
@ -314,6 +315,7 @@ export default function ChatPage() {
<button
onClick={handleSubmit}
disabled={!input.trim() || isLoading}
aria-label="Send message"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-600 text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
>
<Send className="h-4 w-4" />

View file

@ -22,6 +22,22 @@ import { useSessionStore } from "@/hooks/use-session-store";
import { useNotification } from "@/providers/notification-provider";
import { Badge } from "@/components/ui/badge";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const ACRONYMS: Record<string, string> = { mcp: "MCP", llm: "LLM", api: "API" };
/** Convert camelCase key to display label, preserving known acronyms. */
function featureLabel(key: string): string {
return key
.replace(/([A-Z])/g, " $1")
.trim()
.split(" ")
.map((w) => ACRONYMS[w.toLowerCase()] ?? w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
// ---------------------------------------------------------------------------
// Section wrapper
// ---------------------------------------------------------------------------
@ -66,9 +82,11 @@ export default function SettingsPage() {
>([]);
const [loadingCollections, setLoadingCollections] = useState(false);
// Dark mode toggle -- uses a class on <html> and persists to localStorage
// Dark mode toggle -- uses a class on <html>/<body> and persists to localStorage
const [isDark, setIsDark] = useState(() => {
if (typeof window === "undefined") return true;
const saved = localStorage.getItem("tg-theme");
if (saved) return saved === "dark";
return !document.documentElement.classList.contains("light");
});
@ -77,9 +95,13 @@ export default function SettingsPage() {
setIsDark(next);
if (next) {
document.documentElement.classList.remove("light");
document.body.classList.remove("light");
document.body.classList.add("dark");
localStorage.setItem("tg-theme", "dark");
} else {
document.documentElement.classList.add("light");
document.body.classList.add("light");
document.body.classList.remove("dark");
localStorage.setItem("tg-theme", "light");
}
}, [isDark]);
@ -117,9 +139,10 @@ export default function SettingsPage() {
connectionState.status === "authenticated" ||
connectionState.status === "unauthenticated";
const isWarning = connectionState.status === "unauthenticated";
const statusBadge = isConnected ? (
<Badge variant="success">
<Wifi className="h-3 w-3" /> {connectionState.status}
<Badge variant={isWarning ? "info" : "success"}>
<Wifi className="h-3 w-3" /> {isWarning ? "Connected (no auth)" : connectionState.status}
</Badge>
) : (
<Badge variant="error">
@ -136,7 +159,7 @@ export default function SettingsPage() {
</div>
{/* Form */}
<div className="max-w-2xl space-y-5">
<div className="max-w-2xl space-y-5 pb-8 overflow-y-auto">
{/* Connection */}
<Section
title="Connection"
@ -196,6 +219,7 @@ export default function SettingsPage() {
<button
type="button"
onClick={() => setShowApiKey((p) => !p)}
aria-label={showApiKey ? "Hide API key" : "Show API key"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
>
{showApiKey ? (
@ -305,6 +329,9 @@ export default function SettingsPage() {
</p>
</div>
<button
role="switch"
aria-checked={isDark}
aria-label="Dark mode"
onClick={toggleTheme}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
@ -329,9 +356,12 @@ export default function SettingsPage() {
{Object.entries(settings.featureSwitches).map(([key, enabled]) => (
<div key={key} className="flex items-center justify-between">
<div>
<p className="text-sm text-fg capitalize">{key.replace(/([A-Z])/g, " $1").trim()}</p>
<p className="text-sm text-fg">{featureLabel(key)}</p>
</div>
<button
role="switch"
aria-checked={enabled}
aria-label={featureLabel(key)}
onClick={() => updateFeatureSwitches({ [key]: !enabled })}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",

View file

@ -71,9 +71,11 @@ export default function TokenCostPage() {
<div className="flex items-center gap-3">
<Coins className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Token Cost</h1>
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
{costs.length} model{costs.length !== 1 ? "s" : ""}
</span>
{!loading && (
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
{costs.length} model{costs.length !== 1 ? "s" : ""}
</span>
)}
</div>
<button