docs: Comprehensive documentation update for Dashboard V3 and Canvas

## Documentation Updates
- DASHBOARD.md: Updated to V3.0 with Canvas V3 features, file browser, introspection
- DASHBOARD_IMPLEMENTATION_STATUS.md: Marked Canvas V3 features as COMPLETE
- CANVAS.md: New comprehensive guide for Canvas Builder V3 with all features
- CLAUDE.md: Added dashboard quick reference and Canvas V3 features

## Canvas V3 Features Documented
- File Browser: Browse studies directory for model files
- Model Introspection: Auto-discover expressions, solver type, dependencies
- One-Click Add: Add expressions as design variables instantly
- Claude Bug Fixes: WebSocket reconnection, SQL errors resolved
- Health Check: /api/health endpoint for monitoring

## Backend Services
- NX introspection service with expression discovery
- File browser API with type filtering
- Claude session management improvements
- Context builder enhancements

## Frontend Components
- FileBrowser: Modal for file selection with search
- IntrospectionPanel: View discovered model information
- ExpressionSelector: Dropdown for design variable configuration
- Improved chat hooks with reconnection logic

## Plan Documents
- Added RALPH_LOOP_CANVAS_V2/V3 implementation records
- Added ATOMIZER_DASHBOARD_V2_MASTER_PLAN
- Added investigation and sync documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 20:48:58 -05:00
parent 1c7c7aff05
commit ac5e9b4054
23 changed files with 10860 additions and 773 deletions

View File

@@ -7,7 +7,7 @@ import { DesignVarNodeData } from '../../../lib/canvas/schema';
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400">
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
{data.expressionName ? (
<span className="font-mono">{data.expressionName}</span>
) : (

View File

@@ -7,7 +7,7 @@ import { ModelNodeData } from '../../../lib/canvas/schema';
function ModelNodeComponent(props: NodeProps<ModelNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<Box size={16} />} iconColor="text-blue-400" inputs={0}>
<BaseNode {...props} icon={<Box size={16} />} iconColor="text-blue-400" inputs={1} outputs={1}>
{data.filePath ? data.filePath.split(/[/\\]/).pop() : 'No file selected'}
</BaseNode>
);

View File

@@ -1,8 +1,13 @@
/**
* File Browser - Modal for selecting NX model files
* File Browser - Modal for selecting and importing NX model files
*
* Supports three methods:
* 1. Browse existing files in studies folder
* 2. Import from Windows path (paste full path)
* 3. Upload files directly
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
X,
Folder,
@@ -12,6 +17,12 @@ import {
Search,
RefreshCw,
Home,
Upload,
FolderInput,
FileUp,
CheckCircle,
AlertCircle,
Loader2,
} from 'lucide-react';
interface FileBrowserProps {
@@ -20,6 +31,7 @@ interface FileBrowserProps {
onSelect: (filePath: string, fileType: string) => void;
fileTypes?: string[];
initialPath?: string;
studyName?: string;
}
interface FileEntry {
@@ -29,19 +41,57 @@ interface FileEntry {
size?: number;
}
interface RelatedFile {
name: string;
path: string;
size: number;
type: string;
}
type TabType = 'browse' | 'import' | 'upload';
export function FileBrowser({
isOpen,
onClose,
onSelect,
fileTypes = ['.sim', '.prt', '.fem', '.afem'],
initialPath = '',
studyName = 'new_study',
}: FileBrowserProps) {
// Tab state
const [activeTab, setActiveTab] = useState<TabType>('browse');
// Browse tab state
const [currentPath, setCurrentPath] = useState(initialPath);
const [files, setFiles] = useState<FileEntry[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Import tab state
const [importPath, setImportPath] = useState('');
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<{
valid: boolean;
error?: string;
related_files?: RelatedFile[];
} | null>(null);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<{
success: boolean;
imported_files?: { name: string; status: string; path?: string }[];
} | null>(null);
// Upload tab state
const [uploadStudyName, setUploadStudyName] = useState(studyName);
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<{
success: boolean;
uploaded_files?: { name: string; status: string; path?: string }[];
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Load directory for browse tab
const loadDirectory = useCallback(async (path: string) => {
setIsLoading(true);
setError(null);
@@ -67,11 +117,124 @@ export function FileBrowser({
}, [fileTypes]);
useEffect(() => {
if (isOpen) {
if (isOpen && activeTab === 'browse') {
loadDirectory(currentPath);
}
}, [isOpen, currentPath, loadDirectory]);
}, [isOpen, currentPath, loadDirectory, activeTab]);
// Validate external path
const validatePath = async () => {
if (!importPath.trim()) return;
setValidating(true);
setValidationResult(null);
setImportResult(null);
try {
const res = await fetch(
`/api/files/validate-path?path=${encodeURIComponent(importPath.trim())}`
);
const data = await res.json();
setValidationResult(data);
} catch (e) {
setValidationResult({ valid: false, error: 'Failed to validate path' });
} finally {
setValidating(false);
}
};
// Import from path
const handleImport = async () => {
if (!validationResult?.valid) return;
setImporting(true);
setImportResult(null);
try {
const res = await fetch('/api/files/import-from-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_path: importPath.trim(),
study_name: uploadStudyName,
copy_related: true,
}),
});
const data = await res.json();
if (data.success) {
setImportResult(data);
// Find the main file that was imported
const mainFile = data.imported_files?.find(
(f: { name: string; status: string; path?: string }) =>
f.status === 'imported' || f.status === 'skipped'
);
if (mainFile?.path) {
// Auto-select the imported file
const ext = '.' + mainFile.name.split('.').pop()?.toLowerCase();
onSelect(mainFile.path, ext);
setTimeout(onClose, 1500);
}
} else {
setImportResult({ success: false });
}
} catch (e) {
console.error('Import failed:', e);
setImportResult({ success: false });
} finally {
setImporting(false);
}
};
// Handle file upload
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) return;
setUploading(true);
setUploadResult(null);
const formData = new FormData();
for (let i = 0; i < selectedFiles.length; i++) {
formData.append('files', selectedFiles[i]);
}
try {
const res = await fetch(
`/api/files/upload?study_name=${encodeURIComponent(uploadStudyName)}`,
{
method: 'POST',
body: formData,
}
);
const data = await res.json();
if (data.success) {
setUploadResult(data);
// Find the first uploaded file
const mainFile = data.uploaded_files?.find(
(f: { name: string; status: string; path?: string }) => f.status === 'uploaded'
);
if (mainFile?.path) {
const ext = '.' + mainFile.name.split('.').pop()?.toLowerCase();
onSelect(mainFile.path, ext);
setTimeout(onClose, 1500);
}
} else {
setUploadResult({ success: false });
}
} catch (e) {
console.error('Upload failed:', e);
setUploadResult({ success: false });
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// Browse tab handlers
const handleSelect = (file: FileEntry) => {
if (file.isDirectory) {
setCurrentPath(file.path);
@@ -117,124 +280,407 @@ export function FileBrowser({
</button>
</div>
{/* Search */}
<div className="px-4 py-3 border-b border-dark-700">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500"
/>
<input
type="text"
placeholder="Search files..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white placeholder-dark-500 text-sm focus:outline-none focus:border-primary-500"
/>
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-dark-500">
<span>Looking for:</span>
{fileTypes.map((t) => (
<span key={t} className="px-1.5 py-0.5 bg-dark-700 rounded">
{t}
</span>
))}
</div>
</div>
{/* Path breadcrumb */}
<div className="px-4 py-2 text-sm text-dark-400 flex items-center gap-1 border-b border-dark-700 overflow-x-auto">
{/* Tabs */}
<div className="flex border-b border-dark-700">
<button
onClick={() => navigateTo('')}
className="hover:text-white flex items-center gap-1 flex-shrink-0"
onClick={() => setActiveTab('browse')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors
${activeTab === 'browse' ? 'text-primary-400 border-b-2 border-primary-400 -mb-px' : 'text-dark-400 hover:text-white'}`}
>
<Home size={14} />
<span>studies</span>
<Folder size={16} />
Browse Studies
</button>
<button
onClick={() => setActiveTab('import')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors
${activeTab === 'import' ? 'text-primary-400 border-b-2 border-primary-400 -mb-px' : 'text-dark-400 hover:text-white'}`}
>
<FolderInput size={16} />
Import Path
</button>
<button
onClick={() => setActiveTab('upload')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors
${activeTab === 'upload' ? 'text-primary-400 border-b-2 border-primary-400 -mb-px' : 'text-dark-400 hover:text-white'}`}
>
<Upload size={16} />
Upload Files
</button>
{pathParts.map((part, i) => (
<span key={i} className="flex items-center gap-1 flex-shrink-0">
<ChevronRight size={14} />
<button
onClick={() => navigateTo(pathParts.slice(0, i + 1).join('/'))}
className="hover:text-white"
>
{part}
</button>
</span>
))}
</div>
{/* File list */}
<div className="flex-1 overflow-auto p-2">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-dark-500">
<RefreshCw size={20} className="animate-spin mr-2" />
Loading...
</div>
) : error ? (
<div className="flex items-center justify-center h-32 text-red-400">
{error}
</div>
) : filteredFiles.length === 0 ? (
<div className="flex items-center justify-center h-32 text-dark-500">
{searchTerm ? 'No matching files found' : 'No model files in this directory'}
</div>
) : (
<div className="space-y-1">
{/* Show parent directory link if not at root */}
{currentPath && (
{/* Tab Content */}
<div className="flex-1 overflow-auto">
{/* Browse Tab */}
{activeTab === 'browse' && (
<>
{/* Search */}
<div className="px-4 py-3 border-b border-dark-700">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500"
/>
<input
type="text"
placeholder="Search files..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white placeholder-dark-500 text-sm focus:outline-none focus:border-primary-500"
/>
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-dark-500">
<span>Looking for:</span>
{fileTypes.map((t) => (
<span key={t} className="px-1.5 py-0.5 bg-dark-700 rounded">
{t}
</span>
))}
</div>
</div>
{/* Path breadcrumb */}
<div className="px-4 py-2 text-sm text-dark-400 flex items-center gap-1 border-b border-dark-700 overflow-x-auto">
<button
onClick={navigateUp}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
hover:bg-dark-700 transition-colors text-dark-300"
onClick={() => navigateTo('')}
className="hover:text-white flex items-center gap-1 flex-shrink-0"
>
<ChevronDown size={16} className="text-dark-500 rotate-90" />
<Folder size={16} className="text-dark-400" />
<span>..</span>
<Home size={14} />
<span>studies</span>
</button>
{pathParts.map((part, i) => (
<span key={i} className="flex items-center gap-1 flex-shrink-0">
<ChevronRight size={14} />
<button
onClick={() => navigateTo(pathParts.slice(0, i + 1).join('/'))}
className="hover:text-white"
>
{part}
</button>
</span>
))}
</div>
{/* File list */}
<div className="p-2">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-dark-500">
<RefreshCw size={20} className="animate-spin mr-2" />
Loading...
</div>
) : error ? (
<div className="flex items-center justify-center h-32 text-red-400">
{error}
</div>
) : filteredFiles.length === 0 ? (
<div className="flex items-center justify-center h-32 text-dark-500">
{searchTerm ? 'No matching files found' : 'No model files in this directory'}
</div>
) : (
<div className="space-y-1">
{currentPath && (
<button
onClick={navigateUp}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
hover:bg-dark-700 transition-colors text-dark-300"
>
<ChevronDown size={16} className="text-dark-500 rotate-90" />
<Folder size={16} className="text-dark-400" />
<span>..</span>
</button>
)}
{filteredFiles.map((file) => (
<button
key={file.path}
onClick={() => handleSelect(file)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
hover:bg-dark-700 transition-colors
${file.isDirectory ? 'text-dark-300' : 'text-white'}`}
>
{file.isDirectory ? (
<>
<ChevronRight size={16} className="text-dark-500" />
<Folder size={16} className="text-amber-400" />
</>
) : (
<>
<span className="w-4" />
<FileBox size={16} className="text-primary-400" />
</>
)}
<span className="flex-1 truncate">{file.name}</span>
{!file.isDirectory && (
<span className="text-xs text-dark-500 uppercase">
{file.name.split('.').pop()}
</span>
)}
</button>
))}
</div>
)}
</div>
</>
)}
{/* Import Tab */}
{activeTab === 'import' && (
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Windows File Path
</label>
<p className="text-xs text-dark-500 mb-2">
Paste the full path to your NX model file (e.g., C:\Models\bracket_sim1.sim)
</p>
<div className="flex gap-2">
<input
type="text"
value={importPath}
onChange={(e) => {
setImportPath(e.target.value);
setValidationResult(null);
setImportResult(null);
}}
placeholder="C:\path\to\model.sim"
className="flex-1 px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white placeholder-dark-500 font-mono text-sm
focus:outline-none focus:border-primary-500"
/>
<button
onClick={validatePath}
disabled={!importPath.trim() || validating}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:bg-dark-600
text-white rounded-lg text-sm font-medium transition-colors
flex items-center gap-2"
>
{validating ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
Validate
</button>
</div>
</div>
{/* Validation Result */}
{validationResult && (
<div className={`p-3 rounded-lg border ${
validationResult.valid
? 'bg-emerald-500/10 border-emerald-500/30'
: 'bg-red-500/10 border-red-500/30'
}`}>
{validationResult.valid ? (
<>
<div className="flex items-center gap-2 text-emerald-400 font-medium">
<CheckCircle size={16} />
Path validated
</div>
{validationResult.related_files && validationResult.related_files.length > 0 && (
<div className="mt-2">
<p className="text-xs text-dark-400 mb-1">Related files to import:</p>
<div className="space-y-1">
{validationResult.related_files.map((f) => (
<div key={f.path} className="flex items-center gap-2 text-xs text-dark-300">
<FileBox size={12} className="text-primary-400" />
<span className="font-mono">{f.name}</span>
<span className="text-dark-500">{f.type}</span>
</div>
))}
</div>
</div>
)}
</>
) : (
<div className="flex items-center gap-2 text-red-400">
<AlertCircle size={16} />
{validationResult.error}
</div>
)}
</div>
)}
{filteredFiles.map((file) => (
{/* Study Name for Import */}
{validationResult?.valid && (
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Import to Study
</label>
<input
type="text"
value={uploadStudyName}
onChange={(e) => setUploadStudyName(e.target.value)}
placeholder="study_name"
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white placeholder-dark-500 text-sm
focus:outline-none focus:border-primary-500"
/>
</div>
)}
{/* Import Button */}
{validationResult?.valid && (
<button
key={file.path}
onClick={() => handleSelect(file)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
hover:bg-dark-700 transition-colors
${file.isDirectory ? 'text-dark-300' : 'text-white'}`}
onClick={handleImport}
disabled={importing}
className="w-full py-2.5 bg-emerald-500 hover:bg-emerald-600 disabled:bg-dark-600
text-white rounded-lg font-medium transition-colors
flex items-center justify-center gap-2"
>
{file.isDirectory ? (
{importing ? (
<>
<ChevronRight size={16} className="text-dark-500" />
<Folder size={16} className="text-amber-400" />
<Loader2 size={18} className="animate-spin" />
Importing...
</>
) : (
<>
<span className="w-4" />
<FileBox size={16} className="text-primary-400" />
<FolderInput size={18} />
Import All Files
</>
)}
<span className="flex-1 truncate">{file.name}</span>
{!file.isDirectory && (
<span className="text-xs text-dark-500 uppercase">
{file.name.split('.').pop()}
</span>
)}
</button>
))}
)}
{/* Import Result */}
{importResult && (
<div className={`p-3 rounded-lg border ${
importResult.success
? 'bg-emerald-500/10 border-emerald-500/30'
: 'bg-red-500/10 border-red-500/30'
}`}>
{importResult.success ? (
<>
<div className="flex items-center gap-2 text-emerald-400 font-medium">
<CheckCircle size={16} />
Import successful!
</div>
{importResult.imported_files && (
<div className="mt-2 space-y-1">
{importResult.imported_files.map((f) => (
<div key={f.name} className="flex items-center gap-2 text-xs">
<span className={f.status === 'imported' ? 'text-emerald-400' : 'text-dark-400'}>
{f.status === 'imported' ? '✓' : '○'}
</span>
<span className="text-dark-300 font-mono">{f.name}</span>
<span className="text-dark-500">{f.status}</span>
</div>
))}
</div>
)}
</>
) : (
<div className="flex items-center gap-2 text-red-400">
<AlertCircle size={16} />
Import failed
</div>
)}
</div>
)}
</div>
)}
{/* Upload Tab */}
{activeTab === 'upload' && (
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Study Name
</label>
<input
type="text"
value={uploadStudyName}
onChange={(e) => setUploadStudyName(e.target.value)}
placeholder="study_name"
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white placeholder-dark-500 text-sm
focus:outline-none focus:border-primary-500"
/>
</div>
{/* Upload Area */}
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-dark-600 rounded-xl p-8
hover:border-primary-500/50 hover:bg-dark-800/50
transition-colors cursor-pointer"
>
<div className="flex flex-col items-center text-center">
<FileUp size={40} className="text-dark-500 mb-3" />
<p className="text-white font-medium">
{uploading ? 'Uploading...' : 'Click to select files'}
</p>
<p className="text-dark-500 text-sm mt-1">
or drag and drop NX model files
</p>
<p className="text-dark-600 text-xs mt-2">
Supported: .prt, .sim, .fem, .afem
</p>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".prt,.sim,.fem,.afem"
onChange={handleFileUpload}
className="hidden"
/>
</div>
{/* Upload Progress/Result */}
{uploading && (
<div className="flex items-center justify-center gap-2 text-primary-400">
<Loader2 size={18} className="animate-spin" />
Uploading files...
</div>
)}
{uploadResult && (
<div className={`p-3 rounded-lg border ${
uploadResult.success
? 'bg-emerald-500/10 border-emerald-500/30'
: 'bg-red-500/10 border-red-500/30'
}`}>
{uploadResult.success ? (
<>
<div className="flex items-center gap-2 text-emerald-400 font-medium">
<CheckCircle size={16} />
Upload successful!
</div>
{uploadResult.uploaded_files && (
<div className="mt-2 space-y-1">
{uploadResult.uploaded_files.map((f) => (
<div key={f.name} className="flex items-center gap-2 text-xs">
<span className={f.status === 'uploaded' ? 'text-emerald-400' : 'text-red-400'}>
{f.status === 'uploaded' ? '✓' : '✗'}
</span>
<span className="text-dark-300 font-mono">{f.name}</span>
<span className="text-dark-500">{f.status}</span>
</div>
))}
</div>
)}
</>
) : (
<div className="flex items-center gap-2 text-red-400">
<AlertCircle size={16} />
Upload failed
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-dark-700 flex justify-between items-center">
<button
onClick={() => loadDirectory(currentPath)}
className="flex items-center gap-1.5 px-3 py-1.5 text-dark-400 hover:text-white transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
{activeTab === 'browse' && (
<button
onClick={() => loadDirectory(currentPath)}
className="flex items-center gap-1.5 px-3 py-1.5 text-dark-400 hover:text-white transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
)}
{activeTab !== 'browse' && <div />}
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-white transition-colors"