Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added dashboard/public/exospheresmall-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions dashboard/src/app/api/manual-retry-state/route.ts
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;
Comment on lines +3 to +4
Copy link
Contributor

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.

-const API_BASE_URL = process.env.EXOSPHERE_STATE_MANAGER_URI || 'http://localhost:8000';
+const API_BASE_URL = process.env.EXOSPHERE_STATE_MANAGER_URI || 'http://localhost:8000';
+if (process.env.NODE_ENV === 'production' && API_BASE_URL.startsWith('http://')) {
+  throw new Error('EXOSPHERE_STATE_MANAGER_URI must use https in production');
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const API_BASE_URL = process.env.EXOSPHERE_STATE_MANAGER_URI || 'http://localhost:8000';
const API_KEY = process.env.EXOSPHERE_API_KEY;
const API_BASE_URL = process.env.EXOSPHERE_STATE_MANAGER_URI || 'http://localhost:8000';
if (process.env.NODE_ENV === 'production' && API_BASE_URL.startsWith('http://')) {
throw new Error('EXOSPHERE_STATE_MANAGER_URI must use https in production');
}
const API_KEY = process.env.EXOSPHERE_API_KEY;
🤖 Prompt for AI Agents
In dashboard/src/app/api/manual-retry-state/route.ts around lines 3 to 4, the
API_BASE_URL allows plain HTTP which is risky in non-local environments; enforce
HTTPS by validating the value when not running locally and fail-fast if it's
insecure. Update the code to: read API_BASE_URL from env, allow http only if the
host is localhost, 127.0.0.1 or when NODE_ENV is 'development', otherwise
require it to start with "https://"; if the value is missing or uses "http://"
in production, throw an error or exit so the app won’t start with an insecure
endpoint; keep the existing fallback to 'http://localhost:8000' only for
local/dev runs.


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 });
}
🤖 Prompt for AI Agents
In dashboard/src/app/api/manual-retry-state/route.ts around lines 12-14, the
code currently accepts any string for namespace and stateId which allows
slashes/path traversal; validate both using a strict regex (e.g. only letters,
digits, hyphen, underscore) and a sensible max length (e.g. 1-64 chars) and
return a 400 with a clear error if validation fails; update the conditional to
check regex/length for both values and reject inputs containing slashes or
traversal sequences so downstream path interpolation is safe.


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The validation for the request body is not fully robust. request.json() can resolve to any value that can be represented by JSON (e.g., null, a string), not just an object. Accessing body.fanout_id on a non-object will throw a TypeError, leading to an unexpected 500 error instead of a 400 Bad Request. It's better to validate the type of body and fanout_id before accessing it.

Suggested change
if (!body.fanout_id) {
return NextResponse.json({ error: 'fanout_id is required in request body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || typeof body.fanout_id !== 'string' || !body.fanout_id) {
return NextResponse.json({ error: 'A non-empty string `fanout_id` is required in the request body' }, { status: 400 });
}

Comment on lines +22 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!body.fanout_id) {
return NextResponse.json({ error: 'fanout_id is required in request body' }, { status: 400 });
}
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 });
}
🤖 Prompt for AI Agents
In dashboard/src/app/api/manual-retry-state/route.ts around lines 22 to 24, the
code only checks presence of body.fanout_id but does not validate that it is a
UUID v4; update the handler to validate fanout_id format (e.g., using a UUID v4
regex or a utility like validator.isUUID(fanout_id, 4)), and if the check fails
return NextResponse.json({ error: 'fanout_id must be a valid UUID v4' }, {
status: 400 }); ensure the validation runs immediately after the existence check
so downstream receives a correctly formatted UUID.


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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),
});
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),
signal: controller.signal,
});
clearTimeout(timeoutId);
🤖 Prompt for AI Agents
In dashboard/src/app/api/manual-retry-state/route.ts around lines 26 to 33, the
downstream fetch has no timeout and can hang; wrap the fetch with an
AbortController and a setTimeout (configurable, e.g. 5s) that calls
controller.abort() after the timeout, pass controller.signal to fetch, and clear
the timeout timer on success or error to avoid leaks; ensure you catch the abort
error and return an appropriate timeout response.


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);
} catch (error) {
console.error('Error retrying state:', error);
return NextResponse.json(
{ error: 'Failed to retry state' },
{ status: 500 }
);
}
}
63 changes: 58 additions & 5 deletions dashboard/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
--input: #1a2a5a; /* Navy input background */
--ring: #87ceeb; /* Sky blue focus ring */
--chart-1: #87ceeb; /* Sky blue */
--chart-2: #4ade80; /* Green accent */
--chart-3: #fbbf24; /* Yellow accent */
--chart-2: #66d1b5; /* Green accent */
--chart-3: #ffed9e; /* Yellow accent */
--chart-4: #ff6b8a; /* Pink accent */
--chart-5: #a78bfa; /* Purple accent */
--sidebar: #0a1a4a;
Expand All @@ -52,6 +52,44 @@
--sidebar-ring: #87ceeb;
}

/* Light Mode Variables */
.light {
--background: #ffffff; /* White background */
--foreground: #031035; /* Dark navy text */
--card: #f2f7fb; /* Light card background */
--card-foreground: #031035;
--popover: #ffffff;
--popover-foreground: #031035;
--primary: #031035; /* Dark navy primary (keeping dark blue accent) */
--primary-foreground: #ffffff;
--secondary: #f1f5f9; /* Light gray secondary */
--secondary-foreground: #031035;
--muted: #f1f5f9; /* Light muted background */
--muted-foreground: #64748b; /* Medium gray text */
--accent: #031035; /* Keep dark blue accent */
--accent-light: #0a1a4a; /* Keep dark blue accent */
--accent-lighter: #1a2a5a; /* Keep dark blue accent */
--accent-lightest: #2a3a6a; /* Keep dark blue accent */
--accent-foreground: #ffffff;
--destructive: #dc2626; /* Red for errors in light mode */
--border: #e2e8f0; /* Light border */
--input: #ffffff; /* White input background */
--ring: #87ceeb; /* Keep sky blue focus ring */
--chart-1: #87ceeb; /* Sky blue */
--chart-2: #4ade80; /* Green accent */
--chart-3: #cca301; /* Yellow accent */
--chart-4: #ff6b8a; /* Pink accent */
--chart-5: #a78bfa; /* Purple accent */
--sidebar: #f8fafc;
--sidebar-foreground: #031035;
--sidebar-primary: #031035; /* Dark navy for sidebar primary in light mode */
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f1f5f9;
--sidebar-accent-foreground: #031035;
--sidebar-border: #e2e8f0;
--sidebar-ring: #87ceeb;
}

/* Custom Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
Expand Down Expand Up @@ -113,7 +151,18 @@
}
}


/* Light mode react-flow nodes */
.light .react-flow__node{
background-color: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
&:hover{
background-color: var(--muted);
}
&:active{
background-color: var(--muted);
}
}

@theme inline {
--color-background: var(--background);
Expand Down Expand Up @@ -169,9 +218,13 @@
@apply bg-background text-foreground font-sans;
}

/* Custom select dropdown styling for better dark theme support */
/* Custom select dropdown styling for better theme support */
select {
color-scheme: dark;
color-scheme: light dark;
}

.light select {
color-scheme: light;
}

select option {
Expand Down
138 changes: 138 additions & 0 deletions dashboard/src/app/graph/[namespace]/[runId]/page.tsx
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Mutating the template object received from the API directly can lead to unexpected side effects, especially if the data is cached or used elsewhere. It's a better practice to create a new object with the added properties to ensure immutability.

Suggested change
template.name = graphName;
template.namespace = namespace;
setGraphTemplate(template);
setGraphTemplate({
...template,
name: graphName,
namespace: namespace,
});

} 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>
);
}
31 changes: 30 additions & 1 deletion dashboard/src/app/layout.tsx
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";
import "./globals.css";

const geistSans = Geist({
Expand All @@ -24,10 +25,38 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

Silence harmless class hydration diff on .

Add suppressHydrationWarning to avoid warnings when the pre-hydration script sets the class.

-    <html lang="en">
+    <html lang="en" suppressHydrationWarning>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<html lang="en">
<html lang="en" suppressHydrationWarning>
🤖 Prompt for AI Agents
In dashboard/src/app/layout.tsx around line 27, the <html lang="en"> element can
trigger a harmless React hydration warning when a pre-hydration script mutates
its class; add the suppressHydrationWarning attribute to the html element to
silence that warning. Update the JSX for the html tag to include
suppressHydrationWarning so React ignores the mismatch and no other logic needs
changing.

<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>
);
Expand Down
8 changes: 5 additions & 3 deletions dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
'use client';

import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import { GraphTemplateBuilder } from '@/components/GraphTemplateBuilder';
import { NamespaceOverview } from '@/components/NamespaceOverview';
import { RunsTable } from '@/components/RunsTable';
import { NodeDetailModal } from '@/components/NodeDetailModal';
import { GraphTemplateDetailModal } from '@/components/GraphTemplateDetailModal';
import { GraphTemplateDetailModal} from '@/components/GraphTemplateDetailModal';
import { ThemeToggle } from '@/components/ThemeToggle';
import { Logo } from '@/components/Logo';
import { clientApiService } from '@/services/clientApi';
import {
NodeRegistration,
Expand Down Expand Up @@ -139,7 +140,7 @@ export default function Dashboard() {
<div className="flex items-center justify-between h-16">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3">
<Image src="/exospheresmall.png" alt="Exosphere Logo" width={32} height={32} />
<Logo width={32} height={32} />
<div>
<h1 className="text-xl font-bold text-foreground">Exosphere Dashboard</h1>
<p className="text-sm text-muted-foreground">AI Workflow State Manager</p>
Expand Down Expand Up @@ -168,6 +169,7 @@ export default function Dashboard() {
)}
</Select>
</div>
<ThemeToggle />
</div>
</div>
</div>
Expand Down
Loading
Loading