Skip to content

Commit a9a4e0f

Browse files
authored
Merge pull request #30 from ChingEnLin/feat/ui_rework
feat: UI rework — Hub, command palette, workspace persistence, query handover to Explorer
2 parents b945405 + 8305dfb commit a9a4e0f

36 files changed

Lines changed: 6552 additions & 2816 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,9 @@ deployment_note.txt
5050

5151
# Temporary files
5252
development_plans.txt
53+
54+
# claude files
55+
CLAUDE.md
56+
.claude/settings.local.json
57+
HANDOFF.md
58+
DESIGN_HANDBOOK.md

backend/routes/audit.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from fastapi import APIRouter, Header, Body, HTTPException
22
from pydantic import BaseModel
33
from typing import List, Dict, Any, Optional
4-
from services.audit_service import process_audit_question
4+
from services.audit_service import process_audit_question, get_recent_activity
55
from services.gemini_service import VisualizationConfig
6+
from services.user_queries_service import get_user_id_from_token
67

78
router = APIRouter()
89

@@ -18,16 +19,29 @@ class AuditQueryResponse(BaseModel):
1819
visualization: Optional[VisualizationConfig] = None
1920

2021

22+
class RecentActivityItem(BaseModel):
23+
database_name: str
24+
collection_name: str
25+
operation: str
26+
document_id: str
27+
user_email: str
28+
timestamp_utc: str
29+
30+
2131
@router.post("/query", response_model=AuditQueryResponse)
2232
def query_audit_log(
2333
body: AuditQueryRequest = Body(...), authorization: str = Header(...)
2434
):
2535
if not authorization.startswith("Bearer "):
2636
raise HTTPException(status_code=401, detail="Invalid token format")
2737

28-
# We might want to validate the token here even if we don't use it for the pg connection directly yet
29-
# user_token = authorization.replace("Bearer ", "")
30-
# access_token = exchange_token_obo(user_token)
31-
3238
response = process_audit_question(body.question)
3339
return AuditQueryResponse(**response)
40+
41+
42+
@router.get("/recent", response_model=List[RecentActivityItem])
43+
def recent_activity(authorization: str = Header(...), limit: int = 10):
44+
if not authorization.startswith("Bearer "):
45+
raise HTTPException(status_code=401, detail="Invalid token format")
46+
user_email = get_user_id_from_token(authorization.replace("Bearer ", ""))
47+
return get_recent_activity(user_email=user_email, limit=min(limit, 50))

backend/services/audit_service.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Dict, Any
1+
from typing import Dict, Any, List
22
from services.pg_connection import get_connection
33
from services.gemini_service import generate_audit_sql, summarize_audit_results
44

@@ -36,6 +36,36 @@ def execute_audit_query(sql_query: str) -> list:
3636
return [{"error": str(e)}]
3737

3838

39+
def get_recent_activity(user_email: str, limit: int = 10) -> List[Dict[str, Any]]:
40+
"""Returns the most recent write_audit_log rows for a specific user."""
41+
try:
42+
conn = get_connection()
43+
cur = conn.cursor()
44+
cur.execute(
45+
"""
46+
SELECT user_email, operation, database_name, collection_name, document_id, timestamp_utc
47+
FROM write_audit_log
48+
WHERE user_email = %s
49+
ORDER BY timestamp_utc DESC
50+
LIMIT %s
51+
""",
52+
(user_email, limit),
53+
)
54+
columns = [desc[0] for desc in cur.description]
55+
rows = []
56+
for row in cur.fetchall():
57+
item = dict(zip(columns, row))
58+
if hasattr(item.get("timestamp_utc"), "isoformat"):
59+
item["timestamp_utc"] = item["timestamp_utc"].isoformat()
60+
rows.append(item)
61+
cur.close()
62+
conn.close()
63+
return rows
64+
except Exception as e:
65+
print(f"Error fetching recent activity: {e}")
66+
return []
67+
68+
3969
def process_audit_question(question: str) -> Dict[str, Any]:
4070
"""
4171
Orchestrates the process of answering a user's audit question:

backend/services/react_agent_service.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from services.gemini_service import extract_python_code
2222
from services.mongo_service import execute_mongo_query, transform_mongo_result
2323

24-
2524
WRITE_METHODS = {
2625
"insert_one",
2726
"insert_many",
Lines changed: 150 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,170 @@
1-
21
import React from 'react';
3-
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement, PolarAreaController, RadarController, DoughnutController, PieController, BarController, LineController, ScatterController, BubbleController } from 'chart.js';
2+
import {
3+
Chart as ChartJS,
4+
CategoryScale, LinearScale, BarElement,
5+
Title, Tooltip, Legend, ArcElement,
6+
PointElement, LineElement,
7+
PolarAreaController, RadarController,
8+
DoughnutController, PieController,
9+
BarController, LineController,
10+
ScatterController, BubbleController,
11+
} from 'chart.js';
412
import { Chart } from 'react-chartjs-2';
513
import { AnalysisResult } from '../types';
6-
import { AiSparkleIcon } from './icons/material-icons-imports';
714
import { useTheme } from '../contexts/ThemeContext';
815

9-
// Register all necessary Chart.js components
1016
ChartJS.register(
11-
CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement,
12-
PolarAreaController, RadarController, DoughnutController, PieController, BarController, LineController,
13-
ScatterController, BubbleController
17+
CategoryScale, LinearScale, BarElement,
18+
Title, Tooltip, Legend, ArcElement,
19+
PointElement, LineElement,
20+
PolarAreaController, RadarController,
21+
DoughnutController, PieController,
22+
BarController, LineController,
23+
ScatterController, BubbleController,
1424
);
1525

1626
interface AnalysisResultDisplayProps {
17-
result: AnalysisResult;
27+
result: AnalysisResult;
1828
}
1929

30+
const SparkleIcon = () => (
31+
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round">
32+
<path d="M8 1v3M8 12v3M1 8h3M12 8h3M3.05 3.05l2.12 2.12M10.83 10.83l2.12 2.12M3.05 12.95l2.12-2.12M10.83 5.17l2.12-2.12" />
33+
</svg>
34+
);
35+
36+
const CHART_TYPE_LABELS: Record<string, string> = {
37+
bar: 'Bar chart',
38+
line: 'Line chart',
39+
pie: 'Pie chart',
40+
doughnut: 'Doughnut chart',
41+
polarArea: 'Polar area',
42+
radar: 'Radar chart',
43+
scatter: 'Scatter plot',
44+
bubble: 'Bubble chart',
45+
};
46+
2047
const AnalysisResultDisplay: React.FC<AnalysisResultDisplayProps> = ({ result }) => {
21-
const { theme } = useTheme();
48+
const { theme } = useTheme();
49+
50+
const themedChartOptions = React.useMemo(() => {
51+
const isDark = theme === 'dark';
52+
const textColor = isDark ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.45)';
53+
const gridColor = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)';
54+
const base = result.chartOptions || {};
2255

23-
// Dynamically adjust chart options for dark/light theme
24-
const themedChartOptions = React.useMemo(() => {
25-
const isDark = theme === 'dark';
26-
const textColor = isDark ? '#cbd5e1' : '#475569'; // slate-300 : slate-600
27-
const gridColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
56+
return {
57+
...base,
58+
responsive: true,
59+
maintainAspectRatio: false,
60+
plugins: {
61+
...base.plugins,
62+
legend: {
63+
...base.plugins?.legend,
64+
labels: {
65+
...base.plugins?.legend?.labels,
66+
color: textColor,
67+
font: { family: 'Geist, sans-serif', size: 11 },
68+
boxWidth: 10,
69+
padding: 14,
70+
},
71+
},
72+
title: { ...base.plugins?.title, color: textColor },
73+
tooltip: {
74+
...base.plugins?.tooltip,
75+
backgroundColor: isDark ? 'rgba(30,30,36,0.95)' : 'rgba(255,255,255,0.98)',
76+
titleColor: isDark ? '#e2e8f0' : '#1e293b',
77+
bodyColor: isDark ? '#94a3b8' : '#475569',
78+
borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)',
79+
borderWidth: 1,
80+
cornerRadius: 8,
81+
padding: 10,
82+
titleFont: { family: 'Geist, sans-serif', size: 12, weight: '600' },
83+
bodyFont: { family: 'Geist Mono, monospace', size: 11 },
84+
},
85+
},
86+
scales: {
87+
x: {
88+
...base.scales?.x,
89+
ticks: { ...base.scales?.x?.ticks, color: textColor, font: { family: 'Geist Mono, monospace', size: 10 } },
90+
grid: { ...base.scales?.x?.grid, color: gridColor },
91+
border: { color: gridColor },
92+
title: { ...base.scales?.x?.title, color: textColor },
93+
},
94+
y: {
95+
...base.scales?.y,
96+
ticks: { ...base.scales?.y?.ticks, color: textColor, font: { family: 'Geist Mono, monospace', size: 10 } },
97+
grid: { ...base.scales?.y?.grid, color: gridColor },
98+
border: { color: gridColor },
99+
title: { ...base.scales?.y?.title, color: textColor },
100+
},
101+
},
102+
};
103+
}, [result.chartOptions, theme]);
28104

29-
// Deep merge our theme options into the AI-provided options
30-
const baseOptions = result.chartOptions || {};
31-
32-
const themeOverrides = {
33-
plugins: {
34-
legend: {
35-
labels: { color: textColor }
36-
},
37-
title: {
38-
color: textColor
39-
}
40-
},
41-
scales: {
42-
x: {
43-
...baseOptions.scales?.x,
44-
ticks: { ...baseOptions.scales?.x?.ticks, color: textColor },
45-
grid: { ...baseOptions.scales?.x?.grid, color: gridColor },
46-
title: { ...baseOptions.scales?.x?.title, color: textColor }
47-
},
48-
y: {
49-
...baseOptions.scales?.y,
50-
ticks: { ...baseOptions.scales?.y?.ticks, color: textColor },
51-
grid: { ...baseOptions.scales?.y?.grid, color: gridColor },
52-
title: { ...baseOptions.scales?.y?.title, color: textColor }
53-
}
54-
}
55-
};
56-
57-
// A simple deep merge
58-
return {
59-
...baseOptions,
60-
...themeOverrides,
61-
plugins: { ...baseOptions.plugins, ...themeOverrides.plugins },
62-
scales: { ...baseOptions.scales, ...themeOverrides.scales }
63-
};
105+
const chartLabel = CHART_TYPE_LABELS[result.chartType] ?? result.chartType;
64106

65-
}, [result.chartOptions, theme]);
107+
return (
108+
<div
109+
className="qa-card"
110+
style={{ padding: 0, overflow: 'hidden', animation: 'fadeIn 0.25s' }}
111+
>
112+
{/* Header — matches QueryResult card header */}
113+
<div style={{
114+
display: 'flex', alignItems: 'center', gap: 10,
115+
padding: '9px 14px', borderBottom: '1px solid var(--border)', flexShrink: 0,
116+
}}>
117+
<span style={{
118+
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
119+
background: 'var(--accent-soft)',
120+
display: 'flex', alignItems: 'center', justifyContent: 'center',
121+
}}>
122+
<SparkleIcon />
123+
</span>
124+
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--fg)', fontFamily: 'var(--font-body)' }}>
125+
AI Analysis
126+
</span>
127+
<span className="qa-chip accent" style={{ textTransform: 'capitalize', marginLeft: 2 }}>
128+
{chartLabel}
129+
</span>
130+
</div>
131+
132+
{/* Body — insight + chart side by side */}
133+
<div style={{ display: 'flex', minHeight: 320 }}>
134+
{/* Insight panel */}
135+
<div style={{
136+
width: 220, flexShrink: 0,
137+
padding: '16px 18px',
138+
borderRight: '1px solid var(--border)',
139+
display: 'flex', flexDirection: 'column', gap: 10,
140+
background: 'var(--soft)',
141+
}}>
142+
<div style={{
143+
fontSize: 10.5, fontWeight: 500,
144+
textTransform: 'uppercase', letterSpacing: '0.08em',
145+
color: 'var(--muted)', fontFamily: 'var(--font-body)',
146+
}}>
147+
Insight
148+
</div>
149+
<p style={{
150+
fontSize: 13, color: 'var(--fg)', lineHeight: 1.65,
151+
fontFamily: 'var(--font-body)', margin: 0,
152+
}}>
153+
{result.insight}
154+
</p>
155+
</div>
66156

67-
return (
68-
<div className="space-y-4 animate-fade-in">
69-
<h3 className="text-sm font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wider">AI Analysis</h3>
70-
<div className="bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg p-6 flex flex-col md:flex-row gap-6">
71-
{/* Insight Text */}
72-
<div className="md:w-1/3 space-y-4">
73-
<div className="flex items-center gap-3">
74-
<AiSparkleIcon className="w-7 h-7 text-blue-500 flex-shrink-0" />
75-
<h4 className="text-lg font-bold text-slate-800 dark:text-slate-200">Insight</h4>
76-
</div>
77-
<p className="text-slate-600 dark:text-slate-300 text-sm leading-relaxed">
78-
{result.insight}
79-
</p>
80-
</div>
81-
{/* Chart */}
82-
<div className="md:w-2/3 min-h-[300px]">
83-
<Chart
84-
type={result.chartType}
85-
data={result.chartData}
86-
options={themedChartOptions}
87-
/>
88-
</div>
89-
</div>
157+
{/* Chart panel */}
158+
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', minWidth: 0 }}>
159+
<Chart
160+
type={result.chartType}
161+
data={result.chartData}
162+
options={themedChartOptions}
163+
/>
90164
</div>
91-
);
165+
</div>
166+
</div>
167+
);
92168
};
93169

94170
export default AnalysisResultDisplay;

0 commit comments

Comments
 (0)