Skip to content

Commit dcab02b

Browse files
authored
Merge pull request #18 from cristofima/dev - feat: add dataset stats overview and job metadata tracking with tags and notes
- Added tags and notes fields to job records with PATCH endpoint for updates - Created JobMetadataEditor component for inline editing of job metadata - Enhanced error pages (global-error, not-found, loading) with better UX and navigation - Fixed training container imports (preprocessor.py, eda.py) to use relative imports - Updated Terraform CORS configuration to support both Amplify branch and default domains
2 parents 3a0c0cd + 76de7d5 commit dcab02b

22 files changed

Lines changed: 1242 additions & 94 deletions

File tree

backend/api/models/schemas.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ class JobDetails(BaseModel):
113113
metrics: Optional[TrainingMetrics] = None
114114
feature_importance: Optional[Dict[str, float]] = None
115115
error_message: Optional[str] = None
116+
tags: Optional[List[str]] = None # Custom labels for filtering
117+
notes: Optional[str] = None # User notes for experiment tracking
116118

117119
model_config = {"protected_namespaces": ()}
118120

@@ -135,10 +137,18 @@ class JobResponse(BaseModel):
135137
eda_report_download_url: Optional[str] = None
136138
training_report_download_url: Optional[str] = None
137139
error_message: Optional[str] = None
140+
tags: Optional[List[str]] = None # Custom labels for filtering
141+
notes: Optional[str] = None # User notes for experiment tracking
138142

139143
model_config = {"protected_namespaces": ()}
140144

141145

146+
class JobUpdateRequest(BaseModel):
147+
"""Request schema for updating job metadata (tags, notes)"""
148+
tags: Optional[List[str]] = Field(default=None, max_items=10, description="Custom labels for filtering (max 10)")
149+
notes: Optional[str] = Field(default=None, max_length=1000, description="User notes for experiment tracking")
150+
151+
142152
class JobListResponse(BaseModel):
143153
jobs: List[JobDetails]
144154
next_token: Optional[str] = None

backend/api/routers/models.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from fastapi import APIRouter, HTTPException, status, Query
22
from typing import Optional
3-
from ..models.schemas import JobListResponse, JobResponse, JobStatus, ProblemType
3+
from ..models.schemas import JobListResponse, JobResponse, JobStatus, ProblemType, JobUpdateRequest
44
from ..services.dynamo_service import dynamodb_service
55
from ..services.s3_service import s3_service
66
from ..utils.helpers import get_settings
@@ -34,7 +34,9 @@ async def get_job_status(job_id: str):
3434
started_at=job.get('started_at'),
3535
completed_at=job.get('completed_at'),
3636
metrics=job.get('metrics'),
37-
error_message=job.get('error_message')
37+
error_message=job.get('error_message'),
38+
tags=job.get('tags'),
39+
notes=job.get('notes')
3840
)
3941

4042
# Generate download URLs if job is completed
@@ -170,6 +172,68 @@ async def delete_job(job_id: str, delete_data: bool = True):
170172
)
171173

172174

175+
@router.patch("/{job_id}", response_model=JobResponse)
176+
async def update_job_metadata(job_id: str, request: JobUpdateRequest):
177+
"""
178+
Update job metadata (tags and notes) for experiment tracking.
179+
Tags can be used to categorize jobs (e.g., "experiment-1", "baseline", "production").
180+
Notes can store observations or comments about the training run.
181+
"""
182+
try:
183+
# Verify job exists
184+
job = dynamodb_service.get_job(job_id)
185+
if not job:
186+
raise HTTPException(
187+
status_code=status.HTTP_404_NOT_FOUND,
188+
detail="Job not found"
189+
)
190+
191+
# Validate tags if provided
192+
if request.tags is not None:
193+
if len(request.tags) > 10:
194+
raise HTTPException(
195+
status_code=status.HTTP_400_BAD_REQUEST,
196+
detail="Maximum 10 tags allowed per job"
197+
)
198+
# Validate individual tag length
199+
for tag in request.tags:
200+
if not tag.strip():
201+
raise HTTPException(
202+
status_code=status.HTTP_400_BAD_REQUEST,
203+
detail="Tags cannot be empty or whitespace"
204+
)
205+
if len(tag) > 50:
206+
raise HTTPException(
207+
status_code=status.HTTP_400_BAD_REQUEST,
208+
detail="Each tag must be 50 characters or less"
209+
)
210+
211+
# Validate notes length if provided (defense-in-depth, Pydantic also validates)
212+
if request.notes is not None and len(request.notes) > 1000:
213+
raise HTTPException(
214+
status_code=status.HTTP_400_BAD_REQUEST,
215+
detail="Notes must be 1000 characters or less"
216+
)
217+
218+
# Update job metadata in DynamoDB
219+
dynamodb_service.update_job_metadata(
220+
job_id=job_id,
221+
tags=request.tags,
222+
notes=request.notes
223+
)
224+
225+
# Return updated job
226+
return await get_job_status(job_id)
227+
228+
except HTTPException:
229+
raise
230+
except Exception as e:
231+
raise HTTPException(
232+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
233+
detail=f"Error updating job metadata: {str(e)}"
234+
)
235+
236+
173237
@router.get("", response_model=JobListResponse)
174238
async def list_jobs(
175239
limit: int = Query(default=20, ge=1, le=100),

backend/api/services/dynamo_service.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def list_jobs(
129129
limit: int = 20,
130130
last_evaluated_key: Optional[Dict] = None
131131
) -> tuple[List[Dict], Optional[Dict]]:
132-
"""List training jobs for a user with pagination"""
132+
"""List training jobs for a user with pagination, ordered by created_at DESC"""
133133
try:
134134
scan_kwargs = {
135135
'Limit': limit,
@@ -144,6 +144,9 @@ def list_jobs(
144144
items = self._convert_decimals(response.get('Items', []))
145145
next_key = response.get('LastEvaluatedKey')
146146

147+
# Sort by created_at descending (newest first)
148+
items.sort(key=lambda x: x.get('created_at', ''), reverse=True)
149+
147150
return items, next_key
148151
except ClientError as e:
149152
raise Exception(f"Error listing jobs: {str(e)}")
@@ -174,6 +177,38 @@ def delete_job(self, job_id: str) -> bool:
174177
except ClientError as e:
175178
raise Exception(f"Error deleting job: {str(e)}")
176179

180+
def update_job_metadata(
181+
self,
182+
job_id: str,
183+
tags: Optional[List[str]] = None,
184+
notes: Optional[str] = None
185+
) -> bool:
186+
"""Update job metadata (tags, notes) for experiment tracking"""
187+
try:
188+
update_data = {
189+
'updated_at': datetime.now(timezone.utc).isoformat()
190+
}
191+
192+
# Only update fields that are provided
193+
if tags is not None:
194+
update_data['tags'] = tags
195+
if notes is not None and notes.strip() != "":
196+
update_data['notes'] = notes
197+
198+
update_expr = "SET " + ", ".join([f"#{k} = :{k}" for k in update_data.keys()])
199+
expr_attr_names = {f"#{k}": k for k in update_data.keys()}
200+
expr_attr_values = {f":{k}": v for k, v in update_data.items()}
201+
202+
self.jobs_table.update_item(
203+
Key={'job_id': job_id},
204+
UpdateExpression=update_expr,
205+
ExpressionAttributeNames=expr_attr_names,
206+
ExpressionAttributeValues=expr_attr_values
207+
)
208+
return True
209+
except ClientError as e:
210+
raise Exception(f"Error updating job metadata: {str(e)}")
211+
177212
def delete_dataset(self, dataset_id: str) -> bool:
178213
"""Delete a dataset record"""
179214
try:

backend/training/eda.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import List, Tuple
44

55
# Import shared utilities
6-
from .utils import detect_problem_type, is_id_column
6+
from utils import detect_problem_type, is_id_column
77

88

99
def generate_eda_report(df: pd.DataFrame, target_column: str, output_path: str):

backend/training/preprocessor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from feature_engine.selection import DropConstantFeatures, DropDuplicateFeatures
99

1010
# Import shared utilities
11-
from .utils import (
11+
from utils import (
1212
detect_problem_type,
1313
is_id_column,
1414
is_high_cardinality_categorical,

frontend/app/configure/[datasetId]/page.tsx

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useRouter, useParams } from 'next/navigation';
55
import { DatasetMetadata, startTraining, getDatasetMetadata } from '@/lib/api';
66
import { getProblemTypeIcon, getProblemTypeDescription } from '@/lib/utils';
77
import Header from '@/components/Header';
8+
import ColumnStatsDisplay from '@/components/ColumnStatsDisplay';
89

910
export default function ConfigurePage() {
1011
const router = useRouter();
@@ -139,33 +140,13 @@ export default function ConfigurePage() {
139140
<div className="bg-white dark:bg-zinc-800 rounded-lg shadow-lg dark:shadow-zinc-900/50 p-8 space-y-8 transition-colors">
140141
{/* Dataset Info */}
141142
<div>
142-
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Dataset Information</h2>
143-
<div className="bg-gray-50 dark:bg-zinc-900/50 rounded-lg p-4 space-y-2 text-sm">
144-
<div className="flex justify-between">
145-
<span className="text-gray-600 dark:text-gray-400">Dataset ID:</span>
146-
<span className="font-mono text-gray-900 dark:text-gray-100">{datasetId}</span>
147-
</div>
148-
{metadata && (
149-
<>
150-
<div className="flex justify-between">
151-
<span className="text-gray-600 dark:text-gray-400">Filename:</span>
152-
<span className="text-gray-900 dark:text-gray-100">{metadata.filename}</span>
153-
</div>
154-
<div className="flex justify-between">
155-
<span className="text-gray-600 dark:text-gray-400">Rows:</span>
156-
<span className="text-gray-900 dark:text-gray-100">{metadata.row_count.toLocaleString()}</span>
157-
</div>
158-
<div className="flex justify-between">
159-
<span className="text-gray-600 dark:text-gray-400">Columns:</span>
160-
<span className="text-gray-900 dark:text-gray-100">{columns.length}</span>
161-
</div>
162-
</>
163-
)}
164-
<div className="flex justify-between">
165-
<span className="text-gray-600 dark:text-gray-400">Status:</span>
166-
<span className="text-green-600 dark:text-green-400">✓ Uploaded and analyzed</span>
167-
</div>
168-
</div>
143+
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Dataset Overview</h2>
144+
{metadata && (
145+
<ColumnStatsDisplay
146+
metadata={metadata}
147+
selectedColumn={selectedTarget || undefined}
148+
/>
149+
)}
169150
</div>
170151

171152
{/* Column Selection */}

frontend/app/error.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
5+
export default function Error({
6+
error,
7+
reset,
8+
}: {
9+
error: Error & { digest?: string };
10+
reset: () => void;
11+
}) {
12+
useEffect(() => {
13+
// Log the error to console (in production, send to error reporting service)
14+
console.error('Application error:', error);
15+
}, [error]);
16+
17+
// Check if it's a network/API error
18+
const isNetworkError = error.message?.includes('fetch') ||
19+
error.message?.includes('network') ||
20+
error.message?.includes('Failed to');
21+
22+
return (
23+
<div className="min-h-screen bg-gray-50 dark:bg-zinc-900 flex items-center justify-center p-4 transition-colors">
24+
<div className="bg-white dark:bg-zinc-800 rounded-lg shadow-xl dark:shadow-zinc-900/50 p-8 max-w-lg w-full text-center transition-colors">
25+
<div className="text-6xl mb-4">
26+
{isNetworkError ? '🌐' : '⚠️'}
27+
</div>
28+
29+
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
30+
{isNetworkError ? 'Connection Error' : 'Something went wrong'}
31+
</h1>
32+
33+
<p className="text-gray-600 dark:text-gray-400 mb-6">
34+
{isNetworkError
35+
? 'Unable to connect to the server. Please check your internet connection and try again.'
36+
: 'An unexpected error occurred. Our team has been notified.'
37+
}
38+
</p>
39+
40+
{/* Error details for debugging (only in development) */}
41+
{process.env.NODE_ENV === 'development' && (
42+
<div className="mb-6 p-4 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg text-left">
43+
<p className="text-sm font-mono text-red-700 dark:text-red-400 break-all">
44+
{error.message}
45+
</p>
46+
{error.digest && (
47+
<p className="text-xs text-red-500 dark:text-red-500 mt-2">
48+
Error ID: {error.digest}
49+
</p>
50+
)}
51+
</div>
52+
)}
53+
54+
<div className="flex flex-col sm:flex-row gap-3 justify-center">
55+
<button
56+
onClick={reset}
57+
className="px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg font-medium hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-colors cursor-pointer"
58+
>
59+
Try again
60+
</button>
61+
62+
<button
63+
onClick={() => window.location.href = '/'}
64+
className="px-6 py-3 bg-gray-100 dark:bg-zinc-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-zinc-600 transition-colors cursor-pointer"
65+
>
66+
Go to Home
67+
</button>
68+
</div>
69+
70+
{/* Helpful tips */}
71+
{isNetworkError && (
72+
<div className="mt-8 text-left">
73+
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
74+
Troubleshooting tips:
75+
</h3>
76+
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
77+
<li>• Check your internet connection</li>
78+
<li>• Verify the API server is running</li>
79+
<li>• Try refreshing the page</li>
80+
<li>• Clear your browser cache</li>
81+
</ul>
82+
</div>
83+
)}
84+
</div>
85+
</div>
86+
);
87+
}

frontend/app/global-error.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
5+
export default function GlobalError({
6+
error,
7+
reset,
8+
}: {
9+
error: Error & { digest?: string };
10+
reset: () => void;
11+
}) {
12+
useEffect(() => {
13+
// Log the error to console (in production, send to error reporting service)
14+
console.error('Critical application error:', error);
15+
}, [error]);
16+
17+
return (
18+
<html lang="en">
19+
<body className="bg-gray-50 dark:bg-zinc-900">
20+
<div className="min-h-screen flex items-center justify-center p-4">
21+
<div className="bg-white dark:bg-zinc-800 rounded-lg shadow-xl p-8 max-w-lg w-full text-center">
22+
<div className="text-6xl mb-4">💥</div>
23+
24+
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
25+
Critical Error
26+
</h1>
27+
28+
<p className="text-gray-600 dark:text-gray-400 mb-6">
29+
A critical error occurred that prevented the application from loading.
30+
Please try refreshing the page.
31+
</p>
32+
33+
{/* Error details for debugging */}
34+
{process.env.NODE_ENV === 'development' && error.message && (
35+
<div className="mb-6 p-4 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg text-left">
36+
<p className="text-sm font-mono text-red-700 dark:text-red-400 break-all">
37+
{error.message}
38+
</p>
39+
</div>
40+
)}
41+
42+
<div className="flex flex-col sm:flex-row gap-3 justify-center">
43+
<button
44+
onClick={reset}
45+
className="px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors cursor-pointer"
46+
>
47+
Try again
48+
</button>
49+
50+
<button
51+
onClick={() => window.location.href = '/'}
52+
className="px-6 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors cursor-pointer"
53+
>
54+
Reload Application
55+
</button>
56+
</div>
57+
</div>
58+
</div>
59+
</body>
60+
</html>
61+
);
62+
}

0 commit comments

Comments
 (0)