mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
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:
parent
3a80872482
commit
9ef9ef854f
11 changed files with 102 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) : "";
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue