-
Notifications
You must be signed in to change notification settings - Fork 42
feat: implement light mode and theme toggle functionality #406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
11f37bf
b771071
74cd2d4
fa425e4
71df4d3
45c8943
c96e767
e7ad975
942960d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,49 @@ | ||||||||||||||||||||||||||||||||||||||||||
| import { NextRequest, NextResponse } from 'next/server'; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const API_BASE_URL = process.env.EXOSPHERE_STATE_MANAGER_URI || 'http://localhost:8000'; | ||||||||||||||||||||||||||||||||||||||||||
| const API_KEY = process.env.EXOSPHERE_API_KEY; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export async function POST(request: NextRequest) { | ||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||
| const { searchParams } = new URL(request.url); | ||||||||||||||||||||||||||||||||||||||||||
| const namespace = searchParams.get('namespace'); | ||||||||||||||||||||||||||||||||||||||||||
| const stateId = searchParams.get('stateId'); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!namespace || !stateId) { | ||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: 'Namespace and stateId are required' }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+12
to
+14
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate namespace/stateId to prevent path abuse and tighten input contract Currently any string (including slashes or path traversal sequences) is accepted and interpolated into the downstream path. Constrain to a safe charset and reasonable length. Apply this diff: - if (!namespace || !stateId) {
- return NextResponse.json({ error: 'Namespace and stateId are required' }, { status: 400 });
- }
+ if (!namespace || !stateId) {
+ return NextResponse.json({ error: 'Namespace and stateId are required' }, { status: 400 });
+ }
+ const safeId = /^[A-Za-z0-9._-]{1,128}$/;
+ if (!safeId.test(namespace) || !safeId.test(stateId)) {
+ return NextResponse.json({ error: 'Invalid namespace or stateId format' }, { status: 400 });
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!API_KEY) { | ||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: 'API key not configured' }, { status: 500 }); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const body = await request.json(); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!body.fanout_id) { | ||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: 'fanout_id is required in request body' }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+22
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The validation for the request body is not fully robust.
Suggested change
nk-ag marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+22
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick Validate fanout_id as UUID v4 Downstream expects a UUID; enforce format early. - if (!body.fanout_id) {
+ const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+ if (!body.fanout_id || !uuidV4.test(body.fanout_id)) {
return NextResponse.json({ error: 'fanout_id is required in request body' }, { status: 400 });
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const response = await fetch(`${API_BASE_URL}/v0/namespace/${namespace}/state/${stateId}/manual-retry`, { | ||||||||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||
| 'X-API-Key': API_KEY, | ||||||||||||||||||||||||||||||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| body: JSON.stringify(body), | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+26
to
+33
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a request timeout to the downstream fetch External call has no timeout; a hung downstream will tie up server resources. - const response = await fetch(`${API_BASE_URL}/v0/namespace/${namespace}/state/${stateId}/manual-retry`, {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10_000);
+ const response = await fetch(`${API_BASE_URL}/v0/namespace/${namespace}/state/${stateId}/manual-retry`, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
- body: JSON.stringify(body),
+ body: JSON.stringify(body),
+ signal: controller.signal,
});
+ clearTimeout(timeoutId);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!response.ok) { | ||||||||||||||||||||||||||||||||||||||||||
| const errorText = await response.text(); | ||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`State manager API error: ${response.status} ${response.statusText} - ${errorText}`); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const data = await response.json(); | ||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json(data); | ||||||||||||||||||||||||||||||||||||||||||
nk-ag marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||
| console.error('Error retrying state:', error); | ||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||||||||||||
| { error: 'Failed to retry state' }, | ||||||||||||||||||||||||||||||||||||||||||
| { status: 500 } | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,138 @@ | ||||||||||||||||||
| 'use client'; | ||||||||||||||||||
|
|
||||||||||||||||||
| import React, { useState, useCallback } from 'react'; | ||||||||||||||||||
| import { useParams, useRouter } from 'next/navigation'; | ||||||||||||||||||
| import { GraphVisualization } from '@/components/GraphVisualization'; | ||||||||||||||||||
| import { GraphTemplateDetail } from '@/components/GraphTemplateDetail'; | ||||||||||||||||||
| import { ThemeToggle } from '@/components/ThemeToggle'; | ||||||||||||||||||
| import { Button } from '@/components/ui/button'; | ||||||||||||||||||
| import { ArrowLeft } from 'lucide-react'; | ||||||||||||||||||
| import { clientApiService } from '@/services/clientApi'; | ||||||||||||||||||
| import { UpsertGraphTemplateResponse } from '@/types/state-manager'; | ||||||||||||||||||
|
|
||||||||||||||||||
| export default function GraphPage() { | ||||||||||||||||||
| const router = useRouter(); | ||||||||||||||||||
| const params = useParams(); | ||||||||||||||||||
|
|
||||||||||||||||||
| const namespace = params?.namespace as string; | ||||||||||||||||||
| const runId = params?.runId as string; | ||||||||||||||||||
|
|
||||||||||||||||||
| // Graph template state | ||||||||||||||||||
| const [graphTemplate, setGraphTemplate] = useState<UpsertGraphTemplateResponse | null>(null); | ||||||||||||||||||
| const [isLoadingTemplate, setIsLoadingTemplate] = useState(false); | ||||||||||||||||||
| const [templateError, setTemplateError] = useState<string | null>(null); | ||||||||||||||||||
|
|
||||||||||||||||||
| const handleBack = () => { | ||||||||||||||||||
| // Go back to the previous page or close the tab if opened from external link | ||||||||||||||||||
| if (typeof window !== 'undefined') { | ||||||||||||||||||
| if (window.history.length > 1) { | ||||||||||||||||||
| router.back(); | ||||||||||||||||||
| } else { | ||||||||||||||||||
| window.close(); | ||||||||||||||||||
| } | ||||||||||||||||||
| } else { | ||||||||||||||||||
| // Fallback for SSR | ||||||||||||||||||
| router.back(); | ||||||||||||||||||
| } | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| const handleOpenGraphTemplate = useCallback(async (graphName: string) => { | ||||||||||||||||||
| if (!graphName || !namespace) return; | ||||||||||||||||||
|
|
||||||||||||||||||
| try { | ||||||||||||||||||
| setIsLoadingTemplate(true); | ||||||||||||||||||
| setTemplateError(null); | ||||||||||||||||||
| const template = await clientApiService.getGraphTemplate(namespace, graphName); | ||||||||||||||||||
| // Add name and namespace to the template | ||||||||||||||||||
| template.name = graphName; | ||||||||||||||||||
| template.namespace = namespace; | ||||||||||||||||||
| setGraphTemplate(template); | ||||||||||||||||||
|
Comment on lines
+47
to
+49
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mutating the
Suggested change
|
||||||||||||||||||
| } catch (err) { | ||||||||||||||||||
| setTemplateError(err instanceof Error ? err.message : 'Failed to load graph template'); | ||||||||||||||||||
| } finally { | ||||||||||||||||||
| setIsLoadingTemplate(false); | ||||||||||||||||||
| } | ||||||||||||||||||
| }, [namespace]); | ||||||||||||||||||
|
|
||||||||||||||||||
| const handleCloseGraphTemplate = () => { | ||||||||||||||||||
| setGraphTemplate(null); | ||||||||||||||||||
| setTemplateError(null); | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!namespace || !runId) { | ||||||||||||||||||
| return ( | ||||||||||||||||||
| <div className="min-h-screen bg-background flex items-center justify-center"> | ||||||||||||||||||
| <div className="text-center"> | ||||||||||||||||||
| <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> | ||||||||||||||||||
| <p className="text-muted-foreground">Loading...</p> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| ); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| return ( | ||||||||||||||||||
| <div className="min-h-screen bg-background"> | ||||||||||||||||||
| {/* Header */} | ||||||||||||||||||
| <header className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-10"> | ||||||||||||||||||
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | ||||||||||||||||||
| <div className="flex items-center justify-between h-16"> | ||||||||||||||||||
| <div className="flex items-center space-x-4"> | ||||||||||||||||||
| <Button | ||||||||||||||||||
| onClick={handleBack} | ||||||||||||||||||
| variant="ghost" | ||||||||||||||||||
| size="sm" | ||||||||||||||||||
| className="flex items-center space-x-2" | ||||||||||||||||||
| > | ||||||||||||||||||
| <ArrowLeft className="w-4 h-4" /> | ||||||||||||||||||
| <span>Back</span> | ||||||||||||||||||
| </Button> | ||||||||||||||||||
| <div className="h-6 w-px bg-border" /> | ||||||||||||||||||
| <div> | ||||||||||||||||||
| <h1 className="text-xl font-semibold text-foreground"> | ||||||||||||||||||
| Graph Visualization | ||||||||||||||||||
| </h1> | ||||||||||||||||||
| <p className="text-sm text-muted-foreground"> | ||||||||||||||||||
| Namespace: {namespace} | Run: {runId} | ||||||||||||||||||
| </p> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| <ThemeToggle /> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| </header> | ||||||||||||||||||
|
|
||||||||||||||||||
| {/* Main Content */} | ||||||||||||||||||
| <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | ||||||||||||||||||
| <GraphVisualization | ||||||||||||||||||
| namespace={namespace} | ||||||||||||||||||
| runId={runId} | ||||||||||||||||||
| onGraphTemplateRequest={handleOpenGraphTemplate} | ||||||||||||||||||
| /> | ||||||||||||||||||
| </main> | ||||||||||||||||||
|
|
||||||||||||||||||
| {/* Graph Template Detail Modal - Inline at bottom */} | ||||||||||||||||||
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8"> | ||||||||||||||||||
| {templateError && ( | ||||||||||||||||||
| <div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg"> | ||||||||||||||||||
| <p className="text-sm text-destructive">{templateError}</p> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| )} | ||||||||||||||||||
|
|
||||||||||||||||||
| {isLoadingTemplate && ( | ||||||||||||||||||
| <div className="mb-4 p-4 bg-muted rounded-lg"> | ||||||||||||||||||
| <div className="flex items-center"> | ||||||||||||||||||
| <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary mr-2"></div> | ||||||||||||||||||
| <p className="text-sm text-muted-foreground">Loading graph template...</p> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| )} | ||||||||||||||||||
|
|
||||||||||||||||||
| <GraphTemplateDetail | ||||||||||||||||||
| graphTemplate={graphTemplate} | ||||||||||||||||||
| isOpen={!!graphTemplate} | ||||||||||||||||||
| onClose={handleCloseGraphTemplate} | ||||||||||||||||||
| /> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| ); | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,5 +1,6 @@ | ||||||
| import type { Metadata } from "next"; | ||||||
| import { Geist, Geist_Mono } from "next/font/google"; | ||||||
| import { ThemeProvider } from "@/contexts/ThemeContext"; | ||||||
nk-ag marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| import "./globals.css"; | ||||||
|
|
||||||
| const geistSans = Geist({ | ||||||
|
|
@@ -24,10 +25,38 @@ export default function RootLayout({ | |||||
| }>) { | ||||||
| return ( | ||||||
| <html lang="en"> | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick Silence harmless class hydration diff on . Add - <html lang="en">
+ <html lang="en" suppressHydrationWarning>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| <head> | ||||||
| <script | ||||||
| dangerouslySetInnerHTML={{ | ||||||
| __html: ` | ||||||
| (function() { | ||||||
| try { | ||||||
| // Ensure we don't add duplicate classes | ||||||
| document.documentElement.classList.remove('light', 'dark'); | ||||||
|
|
||||||
| var theme = localStorage.getItem('theme'); | ||||||
| if (theme && (theme === 'light' || theme === 'dark')) { | ||||||
| document.documentElement.classList.add(theme); | ||||||
| } else { | ||||||
| var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; | ||||||
| document.documentElement.classList.add(prefersDark ? 'dark' : 'light'); | ||||||
| } | ||||||
| } catch (e) { | ||||||
| // Fallback to dark theme if there's any error | ||||||
| document.documentElement.classList.remove('light', 'dark'); | ||||||
| document.documentElement.classList.add('dark'); | ||||||
| } | ||||||
| })(); | ||||||
| `, | ||||||
| }} | ||||||
| /> | ||||||
| </head> | ||||||
| <body | ||||||
| className={`${geistSans.variable} ${geistMono.variable} antialiased`} | ||||||
| > | ||||||
| {children} | ||||||
| <ThemeProvider> | ||||||
| {children} | ||||||
| </ThemeProvider> | ||||||
| </body> | ||||||
| </html> | ||||||
| ); | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Enforce HTTPS for API_BASE_URL in production
Reduce misconfig/SSRF risk by requiring https when not running locally.
📝 Committable suggestion
🤖 Prompt for AI Agents