Skip to content

Commit 7205314

Browse files
authored
feat: portal UX improvements — encoding fix, collapsible sidebar, provenance redesign (#196)
- Fix emoji/unicode rendering in iframe content by adding charset=UTF-8 meta tag and Blob type to JsxRenderer and HtmlRenderer - Add branded LoadingIndicator component with pulsating logo animation - Make left sidebar collapsible with smooth transition, icon-only mode, and localStorage persistence - Auto-collapse sidebars when deep-linking to dashboard assets - Redesign provenance panel with card layout, tool-specific icons and labels, smart summary extraction, relative timestamps, and detail modal - Default right details sidebar to closed on asset viewer
1 parent 11483fe commit 7205314

File tree

9 files changed

+385
-54
lines changed

9 files changed

+385
-54
lines changed

ui/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
33
import { useAuthStore } from "@/stores/auth";
44
import { LoginForm } from "@/components/LoginForm";
55
import { AppShell } from "@/components/layout/AppShell";
6+
import { LoadingIndicator } from "@/components/LoadingIndicator";
67

78
const queryClient = new QueryClient({
89
defaultOptions: {
@@ -25,7 +26,7 @@ function AuthGate() {
2526
if (loading) {
2627
return (
2728
<div className="flex min-h-screen items-center justify-center">
28-
<div className="text-sm text-muted-foreground">Loading...</div>
29+
<LoadingIndicator />
2930
</div>
3031
);
3132
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useBranding } from "@/api/portal/hooks";
2+
import { useThemeStore } from "@/stores/theme";
3+
4+
export function LoadingIndicator() {
5+
const { data: branding } = useBranding();
6+
const theme = useThemeStore((s) => s.theme);
7+
const isDark =
8+
theme === "dark" ||
9+
(theme === "system" &&
10+
typeof window !== "undefined" &&
11+
window.matchMedia("(prefers-color-scheme: dark)").matches);
12+
13+
const base = import.meta.env.BASE_URL;
14+
const defaultLogo = isDark
15+
? `${base}images/activity-svgrepo-com-white.svg`
16+
: `${base}images/activity-svgrepo-com.svg`;
17+
const logo = isDark
18+
? branding?.portal_logo_dark || branding?.portal_logo || defaultLogo
19+
: branding?.portal_logo_light || branding?.portal_logo || defaultLogo;
20+
21+
return (
22+
<div className="flex min-h-[200px] flex-col items-center justify-center gap-3">
23+
<img
24+
src={logo}
25+
alt=""
26+
className="h-16 w-16 animate-pulse-brand"
27+
onError={(e) => {
28+
(e.target as HTMLImageElement).style.display = "none";
29+
}}
30+
/>
31+
<p className="text-sm text-muted-foreground">Loading...</p>
32+
</div>
33+
);
34+
}
Lines changed: 234 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,223 @@
1-
import type { Provenance } from "@/api/portal/types";
1+
import { useState } from "react";
2+
import {
3+
Database,
4+
Search,
5+
FileText,
6+
Info,
7+
Link2,
8+
Terminal,
9+
type LucideIcon,
10+
} from "lucide-react";
11+
import * as Dialog from "@radix-ui/react-dialog";
12+
import type { Provenance, ProvenanceToolCall } from "@/api/portal/types";
213

314
interface Props {
415
provenance: Provenance;
516
}
617

18+
interface ToolMeta {
19+
label: string;
20+
icon: LucideIcon;
21+
}
22+
23+
const TOOL_LABELS: Record<string, ToolMeta> = {
24+
trino_query: { label: "SQL Query", icon: Database },
25+
trino_execute: { label: "SQL Execute", icon: Database },
26+
trino_describe_table: { label: "Describe Table", icon: Database },
27+
trino_list_tables: { label: "List Tables", icon: Database },
28+
trino_list_schemas: { label: "List Schemas", icon: Database },
29+
trino_list_catalogs: { label: "List Catalogs", icon: Database },
30+
trino_explain: { label: "Query Plan", icon: Database },
31+
datahub_search: { label: "Catalog Search", icon: Search },
32+
datahub_get_schema: { label: "Schema Lookup", icon: FileText },
33+
datahub_get_entity: { label: "Entity Details", icon: Info },
34+
datahub_get_lineage: { label: "Lineage", icon: Link2 },
35+
datahub_get_column_lineage: { label: "Column Lineage", icon: Link2 },
36+
datahub_get_queries: { label: "Saved Queries", icon: FileText },
37+
datahub_get_data_product: { label: "Data Product", icon: Info },
38+
datahub_get_glossary_term: { label: "Glossary Term", icon: FileText },
39+
datahub_list_data_products: { label: "Data Products", icon: Search },
40+
datahub_list_domains: { label: "Domains", icon: Search },
41+
datahub_list_tags: { label: "Tags", icon: Search },
42+
platform_info: { label: "Platform Info", icon: Info },
43+
s3_list_objects: { label: "List Files", icon: FileText },
44+
s3_get_object: { label: "Get File", icon: FileText },
45+
s3_list_buckets: { label: "List Buckets", icon: FileText },
46+
};
47+
48+
function getToolMeta(toolName: string): ToolMeta {
49+
return TOOL_LABELS[toolName] ?? { label: toolName, icon: Terminal };
50+
}
51+
52+
/** Extract a human-readable summary from the raw summary JSON string. */
53+
function extractSummary(call: ProvenanceToolCall): string | null {
54+
const raw = call.summary;
55+
if (!raw) return null;
56+
57+
// Try to parse as JSON to extract useful fields
58+
try {
59+
const parsed = JSON.parse(raw);
60+
if (typeof parsed === "string") return parsed;
61+
62+
// SQL queries
63+
if (parsed.sql) {
64+
const sql = String(parsed.sql).trim();
65+
return sql.length > 120 ? sql.slice(0, 120) + "..." : sql;
66+
}
67+
68+
// Search queries
69+
if (parsed.query) return `"${parsed.query}"`;
70+
71+
// URN-based lookups
72+
if (parsed.urn) return String(parsed.urn);
73+
74+
// Table operations
75+
if (parsed.table) {
76+
const parts = [parsed.catalog, parsed.schema, parsed.table].filter(Boolean);
77+
return parts.join(".");
78+
}
79+
80+
// Bucket/key for S3
81+
if (parsed.bucket) {
82+
return parsed.key ? `${parsed.bucket}/${parsed.key}` : parsed.bucket;
83+
}
84+
85+
// Fall back to first string value
86+
const firstStr = Object.values(parsed).find((v) => typeof v === "string");
87+
if (firstStr) return String(firstStr);
88+
} catch {
89+
// Not JSON — use as-is if short enough
90+
if (raw.length <= 150) return raw;
91+
return raw.slice(0, 147) + "...";
92+
}
93+
94+
return null;
95+
}
96+
97+
/** Pretty-print the raw summary for the detail modal. */
98+
function formatDetail(summary: string | undefined): string {
99+
if (!summary) return "(no parameters)";
100+
try {
101+
return JSON.stringify(JSON.parse(summary), null, 2);
102+
} catch {
103+
return summary;
104+
}
105+
}
106+
107+
function relativeTime(timestamp: string): string {
108+
const now = Date.now();
109+
const then = new Date(timestamp).getTime();
110+
const diff = Math.max(0, now - then);
111+
const seconds = Math.floor(diff / 1000);
112+
if (seconds < 60) return "just now";
113+
const minutes = Math.floor(seconds / 60);
114+
if (minutes < 60) return `${minutes} min ago`;
115+
const hours = Math.floor(minutes / 60);
116+
if (hours < 24) return `${hours}h ago`;
117+
const days = Math.floor(hours / 24);
118+
return `${days}d ago`;
119+
}
120+
121+
function ProvenanceCard({
122+
call,
123+
onClick,
124+
}: {
125+
call: ProvenanceToolCall;
126+
onClick: () => void;
127+
}) {
128+
const meta = getToolMeta(call.tool_name);
129+
const Icon = meta.icon;
130+
const summary = extractSummary(call);
131+
132+
return (
133+
<button
134+
type="button"
135+
onClick={onClick}
136+
className="w-full text-left rounded-md border bg-background p-3 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
137+
>
138+
<div className="flex items-start gap-2.5">
139+
<div className="mt-0.5 rounded bg-muted p-1.5">
140+
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
141+
</div>
142+
<div className="min-w-0 flex-1">
143+
<div className="flex items-center justify-between gap-2">
144+
<span className="text-sm font-medium">{meta.label}</span>
145+
<span
146+
className="shrink-0 text-[11px] text-muted-foreground"
147+
title={new Date(call.timestamp).toLocaleString()}
148+
>
149+
{relativeTime(call.timestamp)}
150+
</span>
151+
</div>
152+
{summary && (
153+
<p className="mt-0.5 truncate text-xs text-muted-foreground font-mono">
154+
{summary}
155+
</p>
156+
)}
157+
</div>
158+
</div>
159+
</button>
160+
);
161+
}
162+
163+
function DetailModal({
164+
call,
165+
open,
166+
onOpenChange,
167+
}: {
168+
call: ProvenanceToolCall | null;
169+
open: boolean;
170+
onOpenChange: (open: boolean) => void;
171+
}) {
172+
if (!call) return null;
173+
const meta = getToolMeta(call.tool_name);
174+
const Icon = meta.icon;
175+
const detail = formatDetail(call.summary);
176+
177+
return (
178+
<Dialog.Root open={open} onOpenChange={onOpenChange}>
179+
<Dialog.Portal>
180+
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
181+
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-card p-6 shadow-lg focus:outline-none">
182+
<Dialog.Title className="flex items-center gap-2 text-base font-semibold">
183+
<Icon className="h-4 w-4 text-muted-foreground" />
184+
{meta.label}
185+
</Dialog.Title>
186+
<Dialog.Description className="mt-1 text-xs text-muted-foreground">
187+
{call.tool_name} &middot; {new Date(call.timestamp).toLocaleString()}
188+
</Dialog.Description>
189+
190+
<div className="mt-4">
191+
<p className="mb-1.5 text-xs font-medium text-muted-foreground">
192+
{call.tool_name.startsWith("trino_") && detail.includes("SELECT")
193+
? "SQL Query"
194+
: "Parameters"}
195+
</p>
196+
<pre className="max-h-72 overflow-auto rounded-md bg-muted p-3 text-xs font-mono whitespace-pre-wrap break-words">
197+
{detail}
198+
</pre>
199+
</div>
200+
201+
<div className="mt-4 flex justify-end">
202+
<Dialog.Close asChild>
203+
<button
204+
type="button"
205+
className="rounded-md bg-secondary px-3 py-1.5 text-sm font-medium text-secondary-foreground hover:bg-secondary/80"
206+
>
207+
Close
208+
</button>
209+
</Dialog.Close>
210+
</div>
211+
</Dialog.Content>
212+
</Dialog.Portal>
213+
</Dialog.Root>
214+
);
215+
}
216+
7217
export function ProvenancePanel({ provenance }: Props) {
8218
const calls = provenance.tool_calls ?? [];
219+
const [selected, setSelected] = useState<ProvenanceToolCall | null>(null);
220+
9221
if (calls.length === 0) {
10222
return (
11223
<p className="text-sm text-muted-foreground">No provenance data available.</p>
@@ -14,25 +226,30 @@ export function ProvenancePanel({ provenance }: Props) {
14226

15227
return (
16228
<div className="space-y-3">
17-
<h3 className="text-sm font-medium">Provenance</h3>
18-
<div className="relative pl-4 border-l-2 border-primary/20 space-y-3">
229+
<div className="flex items-center justify-between">
230+
<h3 className="text-sm font-medium">Provenance</h3>
231+
<span className="text-xs text-muted-foreground">
232+
{calls.length} {calls.length === 1 ? "call" : "calls"}
233+
</span>
234+
</div>
235+
236+
<div className="space-y-2">
19237
{calls.map((call, i) => (
20-
<div key={i} className="relative">
21-
<div className="absolute -left-[calc(0.5rem+1px)] top-1.5 h-2 w-2 rounded-full bg-primary" />
22-
<div className="text-sm">
23-
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
24-
{call.tool_name}
25-
</span>
26-
{call.summary && (
27-
<p className="text-muted-foreground mt-0.5">{call.summary}</p>
28-
)}
29-
<p className="text-xs text-muted-foreground mt-0.5">
30-
{new Date(call.timestamp).toLocaleString()}
31-
</p>
32-
</div>
33-
</div>
238+
<ProvenanceCard
239+
key={i}
240+
call={call}
241+
onClick={() => setSelected(call)}
242+
/>
34243
))}
35244
</div>
245+
246+
<DetailModal
247+
call={selected}
248+
open={selected !== null}
249+
onOpenChange={(open) => {
250+
if (!open) setSelected(null);
251+
}}
252+
/>
36253
</div>
37254
);
38255
}

0 commit comments

Comments
 (0)