easy_ml 0.1.4 → 0.2.0.pre.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +234 -26
- data/Rakefile +45 -0
- data/app/controllers/easy_ml/application_controller.rb +67 -0
- data/app/controllers/easy_ml/columns_controller.rb +38 -0
- data/app/controllers/easy_ml/datasets_controller.rb +156 -0
- data/app/controllers/easy_ml/datasources_controller.rb +88 -0
- data/app/controllers/easy_ml/deploys_controller.rb +20 -0
- data/app/controllers/easy_ml/models_controller.rb +151 -0
- data/app/controllers/easy_ml/retraining_runs_controller.rb +19 -0
- data/app/controllers/easy_ml/settings_controller.rb +59 -0
- data/app/frontend/components/AlertProvider.tsx +108 -0
- data/app/frontend/components/DatasetPreview.tsx +161 -0
- data/app/frontend/components/EmptyState.tsx +28 -0
- data/app/frontend/components/ModelCard.tsx +255 -0
- data/app/frontend/components/ModelDetails.tsx +334 -0
- data/app/frontend/components/ModelForm.tsx +384 -0
- data/app/frontend/components/Navigation.tsx +300 -0
- data/app/frontend/components/Pagination.tsx +72 -0
- data/app/frontend/components/Popover.tsx +55 -0
- data/app/frontend/components/PredictionStream.tsx +105 -0
- data/app/frontend/components/ScheduleModal.tsx +726 -0
- data/app/frontend/components/SearchInput.tsx +23 -0
- data/app/frontend/components/SearchableSelect.tsx +132 -0
- data/app/frontend/components/dataset/AutosaveIndicator.tsx +39 -0
- data/app/frontend/components/dataset/ColumnConfigModal.tsx +431 -0
- data/app/frontend/components/dataset/ColumnFilters.tsx +256 -0
- data/app/frontend/components/dataset/ColumnList.tsx +101 -0
- data/app/frontend/components/dataset/FeatureConfigPopover.tsx +57 -0
- data/app/frontend/components/dataset/FeaturePicker.tsx +205 -0
- data/app/frontend/components/dataset/PreprocessingConfig.tsx +704 -0
- data/app/frontend/components/dataset/SplitConfigurator.tsx +120 -0
- data/app/frontend/components/dataset/splitters/DateSplitter.tsx +58 -0
- data/app/frontend/components/dataset/splitters/KFoldSplitter.tsx +68 -0
- data/app/frontend/components/dataset/splitters/LeavePOutSplitter.tsx +29 -0
- data/app/frontend/components/dataset/splitters/PredefinedSplitter.tsx +146 -0
- data/app/frontend/components/dataset/splitters/RandomSplitter.tsx +85 -0
- data/app/frontend/components/dataset/splitters/StratifiedSplitter.tsx +79 -0
- data/app/frontend/components/dataset/splitters/constants.ts +77 -0
- data/app/frontend/components/dataset/splitters/types.ts +168 -0
- data/app/frontend/components/dataset/splitters/utils.ts +53 -0
- data/app/frontend/components/features/CodeEditor.tsx +46 -0
- data/app/frontend/components/features/DataPreview.tsx +150 -0
- data/app/frontend/components/features/FeatureCard.tsx +88 -0
- data/app/frontend/components/features/FeatureForm.tsx +235 -0
- data/app/frontend/components/features/FeatureGroupCard.tsx +54 -0
- data/app/frontend/components/settings/PluginSettings.tsx +81 -0
- data/app/frontend/components/ui/badge.tsx +44 -0
- data/app/frontend/components/ui/collapsible.tsx +9 -0
- data/app/frontend/components/ui/scroll-area.tsx +46 -0
- data/app/frontend/components/ui/separator.tsx +29 -0
- data/app/frontend/entrypoints/App.tsx +40 -0
- data/app/frontend/entrypoints/Application.tsx +24 -0
- data/app/frontend/hooks/useAutosave.ts +61 -0
- data/app/frontend/layouts/Layout.tsx +38 -0
- data/app/frontend/lib/utils.ts +6 -0
- data/app/frontend/mockData.ts +272 -0
- data/app/frontend/pages/DatasetDetailsPage.tsx +103 -0
- data/app/frontend/pages/DatasetsPage.tsx +261 -0
- data/app/frontend/pages/DatasourceFormPage.tsx +147 -0
- data/app/frontend/pages/DatasourcesPage.tsx +261 -0
- data/app/frontend/pages/EditModelPage.tsx +45 -0
- data/app/frontend/pages/EditTransformationPage.tsx +56 -0
- data/app/frontend/pages/ModelsPage.tsx +115 -0
- data/app/frontend/pages/NewDatasetPage.tsx +366 -0
- data/app/frontend/pages/NewModelPage.tsx +45 -0
- data/app/frontend/pages/NewTransformationPage.tsx +43 -0
- data/app/frontend/pages/SettingsPage.tsx +272 -0
- data/app/frontend/pages/ShowModelPage.tsx +30 -0
- data/app/frontend/pages/TransformationsPage.tsx +95 -0
- data/app/frontend/styles/application.css +100 -0
- data/app/frontend/types/dataset.ts +146 -0
- data/app/frontend/types/datasource.ts +33 -0
- data/app/frontend/types/preprocessing.ts +1 -0
- data/app/frontend/types.ts +113 -0
- data/app/helpers/easy_ml/application_helper.rb +10 -0
- data/app/jobs/easy_ml/application_job.rb +21 -0
- data/app/jobs/easy_ml/batch_job.rb +46 -0
- data/app/jobs/easy_ml/compute_feature_job.rb +19 -0
- data/app/jobs/easy_ml/deploy_job.rb +13 -0
- data/app/jobs/easy_ml/finalize_feature_job.rb +15 -0
- data/app/jobs/easy_ml/refresh_dataset_job.rb +32 -0
- data/app/jobs/easy_ml/schedule_retraining_job.rb +11 -0
- data/app/jobs/easy_ml/sync_datasource_job.rb +17 -0
- data/app/jobs/easy_ml/training_job.rb +62 -0
- data/app/models/easy_ml/adapters/base_adapter.rb +45 -0
- data/app/models/easy_ml/adapters/polars_adapter.rb +77 -0
- data/app/models/easy_ml/cleaner.rb +82 -0
- data/app/models/easy_ml/column.rb +124 -0
- data/app/models/easy_ml/column_history.rb +30 -0
- data/app/models/easy_ml/column_list.rb +122 -0
- data/app/models/easy_ml/concerns/configurable.rb +61 -0
- data/app/models/easy_ml/concerns/versionable.rb +19 -0
- data/app/models/easy_ml/dataset.rb +767 -0
- data/app/models/easy_ml/dataset_history.rb +56 -0
- data/app/models/easy_ml/datasource.rb +182 -0
- data/app/models/easy_ml/datasource_history.rb +24 -0
- data/app/models/easy_ml/datasources/base_datasource.rb +54 -0
- data/app/models/easy_ml/datasources/file_datasource.rb +58 -0
- data/app/models/easy_ml/datasources/polars_datasource.rb +89 -0
- data/app/models/easy_ml/datasources/s3_datasource.rb +97 -0
- data/app/models/easy_ml/deploy.rb +114 -0
- data/app/models/easy_ml/event.rb +79 -0
- data/app/models/easy_ml/feature.rb +437 -0
- data/app/models/easy_ml/feature_history.rb +38 -0
- data/app/models/easy_ml/model.rb +575 -41
- data/app/models/easy_ml/model_file.rb +133 -0
- data/app/models/easy_ml/model_file_history.rb +24 -0
- data/app/models/easy_ml/model_history.rb +51 -0
- data/app/models/easy_ml/models/base_model.rb +58 -0
- data/app/models/easy_ml/models/hyperparameters/base.rb +99 -0
- data/app/models/easy_ml/models/hyperparameters/xgboost/dart.rb +82 -0
- data/app/models/easy_ml/models/hyperparameters/xgboost/gblinear.rb +82 -0
- data/app/models/easy_ml/models/hyperparameters/xgboost/gbtree.rb +97 -0
- data/app/models/easy_ml/models/hyperparameters/xgboost.rb +71 -0
- data/app/models/easy_ml/models/xgboost/evals_callback.rb +138 -0
- data/app/models/easy_ml/models/xgboost/progress_callback.rb +39 -0
- data/app/models/easy_ml/models/xgboost.rb +544 -5
- data/app/models/easy_ml/prediction.rb +44 -0
- data/app/models/easy_ml/retraining_job.rb +278 -0
- data/app/models/easy_ml/retraining_run.rb +184 -0
- data/app/models/easy_ml/settings.rb +37 -0
- data/app/models/easy_ml/splitter.rb +90 -0
- data/app/models/easy_ml/splitters/base_splitter.rb +28 -0
- data/app/models/easy_ml/splitters/date_splitter.rb +91 -0
- data/app/models/easy_ml/splitters/predefined_splitter.rb +74 -0
- data/app/models/easy_ml/splitters/random_splitter.rb +82 -0
- data/app/models/easy_ml/tuner_job.rb +56 -0
- data/app/models/easy_ml/tuner_run.rb +31 -0
- data/app/models/splitter_history.rb +6 -0
- data/app/serializers/easy_ml/column_serializer.rb +27 -0
- data/app/serializers/easy_ml/dataset_serializer.rb +73 -0
- data/app/serializers/easy_ml/datasource_serializer.rb +64 -0
- data/app/serializers/easy_ml/feature_serializer.rb +27 -0
- data/app/serializers/easy_ml/model_serializer.rb +90 -0
- data/app/serializers/easy_ml/retraining_job_serializer.rb +22 -0
- data/app/serializers/easy_ml/retraining_run_serializer.rb +39 -0
- data/app/serializers/easy_ml/settings_serializer.rb +9 -0
- data/app/views/layouts/easy_ml/application.html.erb +15 -0
- data/config/initializers/resque.rb +3 -0
- data/config/resque-pool.yml +6 -0
- data/config/routes.rb +39 -0
- data/config/spring.rb +1 -0
- data/config/vite.json +15 -0
- data/lib/easy_ml/configuration.rb +64 -0
- data/lib/easy_ml/core/evaluators/base_evaluator.rb +53 -0
- data/lib/easy_ml/core/evaluators/classification_evaluators.rb +126 -0
- data/lib/easy_ml/core/evaluators/regression_evaluators.rb +66 -0
- data/lib/easy_ml/core/model_evaluator.rb +161 -89
- data/lib/easy_ml/core/tuner/adapters/base_adapter.rb +28 -18
- data/lib/easy_ml/core/tuner/adapters/xgboost_adapter.rb +4 -25
- data/lib/easy_ml/core/tuner.rb +123 -62
- data/lib/easy_ml/core.rb +0 -3
- data/lib/easy_ml/core_ext/hash.rb +24 -0
- data/lib/easy_ml/core_ext/pathname.rb +11 -5
- data/lib/easy_ml/data/date_converter.rb +90 -0
- data/lib/easy_ml/data/filter_extensions.rb +31 -0
- data/lib/easy_ml/data/polars_column.rb +126 -0
- data/lib/easy_ml/data/polars_reader.rb +297 -0
- data/lib/easy_ml/data/preprocessor.rb +280 -142
- data/lib/easy_ml/data/simple_imputer.rb +255 -0
- data/lib/easy_ml/data/splits/file_split.rb +252 -0
- data/lib/easy_ml/data/splits/in_memory_split.rb +54 -0
- data/lib/easy_ml/data/splits/split.rb +95 -0
- data/lib/easy_ml/data/splits.rb +9 -0
- data/lib/easy_ml/data/statistics_learner.rb +93 -0
- data/lib/easy_ml/data/synced_directory.rb +341 -0
- data/lib/easy_ml/data.rb +6 -2
- data/lib/easy_ml/engine.rb +105 -6
- data/lib/easy_ml/feature_store.rb +227 -0
- data/lib/easy_ml/features.rb +61 -0
- data/lib/easy_ml/initializers/inflections.rb +17 -3
- data/lib/easy_ml/logging.rb +2 -2
- data/lib/easy_ml/predict.rb +74 -0
- data/lib/easy_ml/railtie/generators/migration/migration_generator.rb +192 -36
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_column_histories.rb.tt +9 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_columns.rb.tt +25 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_dataset_histories.rb.tt +9 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_datasets.rb.tt +31 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_datasource_histories.rb.tt +9 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_datasources.rb.tt +16 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_deploys.rb.tt +24 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_events.rb.tt +20 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_feature_histories.rb.tt +14 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_features.rb.tt +32 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_model_file_histories.rb.tt +9 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_model_files.rb.tt +17 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_model_histories.rb.tt +9 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_models.rb.tt +20 -9
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_predictions.rb.tt +17 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_retraining_jobs.rb.tt +77 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_settings.rb.tt +9 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_splitter_histories.rb.tt +9 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_splitters.rb.tt +15 -0
- data/lib/easy_ml/railtie/templates/migration/create_easy_ml_tuner_jobs.rb.tt +40 -0
- data/lib/easy_ml/support/est.rb +5 -1
- data/lib/easy_ml/support/file_rotate.rb +79 -15
- data/lib/easy_ml/support/file_support.rb +9 -0
- data/lib/easy_ml/support/local_file.rb +24 -0
- data/lib/easy_ml/support/lockable.rb +62 -0
- data/lib/easy_ml/support/synced_file.rb +103 -0
- data/lib/easy_ml/support/utc.rb +5 -1
- data/lib/easy_ml/support.rb +6 -3
- data/lib/easy_ml/version.rb +4 -1
- data/lib/easy_ml.rb +7 -2
- metadata +355 -72
- data/app/models/easy_ml/models.rb +0 -5
- data/lib/easy_ml/core/model.rb +0 -30
- data/lib/easy_ml/core/model_core.rb +0 -181
- data/lib/easy_ml/core/models/hyperparameters/base.rb +0 -34
- data/lib/easy_ml/core/models/hyperparameters/xgboost.rb +0 -19
- data/lib/easy_ml/core/models/xgboost.rb +0 -10
- data/lib/easy_ml/core/models/xgboost_core.rb +0 -220
- data/lib/easy_ml/core/models.rb +0 -10
- data/lib/easy_ml/core/uploaders/model_uploader.rb +0 -24
- data/lib/easy_ml/core/uploaders.rb +0 -7
- data/lib/easy_ml/data/dataloader.rb +0 -6
- data/lib/easy_ml/data/dataset/data/preprocessor/statistics.json +0 -31
- data/lib/easy_ml/data/dataset/data/sample_info.json +0 -1
- data/lib/easy_ml/data/dataset/dataset/files/sample_info.json +0 -1
- data/lib/easy_ml/data/dataset/splits/file_split.rb +0 -140
- data/lib/easy_ml/data/dataset/splits/in_memory_split.rb +0 -49
- data/lib/easy_ml/data/dataset/splits/split.rb +0 -98
- data/lib/easy_ml/data/dataset/splits.rb +0 -11
- data/lib/easy_ml/data/dataset/splitters/date_splitter.rb +0 -43
- data/lib/easy_ml/data/dataset/splitters.rb +0 -9
- data/lib/easy_ml/data/dataset.rb +0 -430
- data/lib/easy_ml/data/datasource/datasource_factory.rb +0 -60
- data/lib/easy_ml/data/datasource/file_datasource.rb +0 -40
- data/lib/easy_ml/data/datasource/merged_datasource.rb +0 -64
- data/lib/easy_ml/data/datasource/polars_datasource.rb +0 -41
- data/lib/easy_ml/data/datasource/s3_datasource.rb +0 -89
- data/lib/easy_ml/data/datasource.rb +0 -33
- data/lib/easy_ml/data/preprocessor/preprocessor.rb +0 -205
- data/lib/easy_ml/data/preprocessor/simple_imputer.rb +0 -402
- data/lib/easy_ml/deployment.rb +0 -5
- data/lib/easy_ml/support/synced_directory.rb +0 -134
- data/lib/easy_ml/transforms.rb +0 -29
- /data/{lib/easy_ml/core → app/models/easy_ml}/models/hyperparameters.rb +0 -0
@@ -0,0 +1,384 @@
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
2
|
+
// import { useNavigate } from 'react-router-dom';
|
3
|
+
import { TrainFront, Lock, AlertCircle } from 'lucide-react';
|
4
|
+
import { SearchableSelect } from './SearchableSelect';
|
5
|
+
import { ScheduleModal } from './ScheduleModal';
|
6
|
+
import { router } from '@inertiajs/react';
|
7
|
+
import { useInertiaForm } from 'use-inertia-form';
|
8
|
+
import { usePage } from '@inertiajs/react';
|
9
|
+
import type { Dataset } from '../types';
|
10
|
+
|
11
|
+
interface ModelFormProps {
|
12
|
+
initialData?: {
|
13
|
+
id: number;
|
14
|
+
name: string;
|
15
|
+
modelType: string;
|
16
|
+
datasetId: number;
|
17
|
+
task: string;
|
18
|
+
objective?: string;
|
19
|
+
metrics?: string[];
|
20
|
+
retraining_job?: {
|
21
|
+
frequency: string;
|
22
|
+
at: {
|
23
|
+
hour: number;
|
24
|
+
day_of_week?: string;
|
25
|
+
day_of_month?: number;
|
26
|
+
};
|
27
|
+
batch_mode?: string;
|
28
|
+
batch_size?: number;
|
29
|
+
batch_overlap?: number;
|
30
|
+
batch_key?: string;
|
31
|
+
tuning_frequency?: string;
|
32
|
+
active: boolean;
|
33
|
+
metric?: string;
|
34
|
+
threshold?: number;
|
35
|
+
tuner_config?: {
|
36
|
+
n_trials: number;
|
37
|
+
objective: string;
|
38
|
+
config: Record<string, any>;
|
39
|
+
};
|
40
|
+
tuning_enabled?: boolean;
|
41
|
+
};
|
42
|
+
};
|
43
|
+
datasets: Array<Dataset>;
|
44
|
+
constants: {
|
45
|
+
tasks: { value: string; label: string }[];
|
46
|
+
objectives: Record<string, { value: string; label: string; description?: string }[]>;
|
47
|
+
metrics: Record<string, { value: string; label: string; direction: string }[]>;
|
48
|
+
timezone: string;
|
49
|
+
retraining_job_constants: any;
|
50
|
+
tuner_job_constants: any;
|
51
|
+
};
|
52
|
+
isEditing?: boolean;
|
53
|
+
errors?: any;
|
54
|
+
}
|
55
|
+
|
56
|
+
const ErrorDisplay = ({ error }: { error?: string }) => (
|
57
|
+
error ? (
|
58
|
+
<div className="mt-1 flex items-center gap-1 text-sm text-red-600">
|
59
|
+
<AlertCircle className="w-4 h-4" />
|
60
|
+
{error}
|
61
|
+
</div>
|
62
|
+
) : null
|
63
|
+
);
|
64
|
+
|
65
|
+
export function ModelForm({ initialData, datasets, constants, isEditing, errors: initialErrors }: ModelFormProps) {
|
66
|
+
const { rootPath } = usePage().props;
|
67
|
+
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
68
|
+
const [isDataSet, setIsDataSet] = useState(false);
|
69
|
+
|
70
|
+
const form = useInertiaForm({
|
71
|
+
model: {
|
72
|
+
id: initialData?.id,
|
73
|
+
name: initialData?.name || '',
|
74
|
+
model_type: initialData?.model_type || 'xgboost',
|
75
|
+
dataset_id: initialData?.dataset_id || '',
|
76
|
+
task: initialData?.task || 'classification',
|
77
|
+
objective: initialData?.objective || 'binary:logistic',
|
78
|
+
metrics: initialData?.metrics || ['accuracy'],
|
79
|
+
retraining_job_attributes: initialData?.retraining_job ? {
|
80
|
+
id: initialData.retraining_job.id,
|
81
|
+
frequency: initialData.retraining_job.frequency,
|
82
|
+
tuning_frequency: initialData.retraining_job.tuning_frequency || 'month',
|
83
|
+
batch_mode: initialData.retraining_job.batch_mode,
|
84
|
+
batch_size: initialData.retraining_job.batch_size,
|
85
|
+
batch_overlap: initialData.retraining_job.batch_overlap,
|
86
|
+
batch_key: initialData.retraining_job.batch_key,
|
87
|
+
at: {
|
88
|
+
hour: initialData.retraining_job.at?.hour ?? 2,
|
89
|
+
day_of_week: initialData.retraining_job.at?.day_of_week ?? 1,
|
90
|
+
day_of_month: initialData.retraining_job.at?.day_of_month ?? 1
|
91
|
+
},
|
92
|
+
active: initialData.retraining_job.active,
|
93
|
+
metric: initialData.retraining_job.metric,
|
94
|
+
threshold: initialData.retraining_job.threshold,
|
95
|
+
tuner_config: initialData.retraining_job.tuner_config,
|
96
|
+
tuning_enabled: initialData.retraining_job.tuning_enabled || false,
|
97
|
+
} : undefined
|
98
|
+
}
|
99
|
+
});
|
100
|
+
|
101
|
+
const { data, setData, post, patch, processing, errors: formErrors } = form;
|
102
|
+
const errors = { ...initialErrors, ...formErrors };
|
103
|
+
|
104
|
+
const objectives: { value: string; label: string; description?: string }[] =
|
105
|
+
constants.objectives[data.model.model_type]?.[data.model.task] || [];
|
106
|
+
|
107
|
+
useEffect(() => {
|
108
|
+
// Only set default metrics if none were provided from the backend
|
109
|
+
if (!initialData?.metrics) {
|
110
|
+
const availableMetrics = constants.metrics[data.model.task]?.map(metric => metric.value) || [];
|
111
|
+
setData({
|
112
|
+
...data,
|
113
|
+
model: {
|
114
|
+
...data.model,
|
115
|
+
objective: data.model.task === 'classification' ? 'binary:logistic' : 'reg:squarederror',
|
116
|
+
metrics: availableMetrics
|
117
|
+
}
|
118
|
+
});
|
119
|
+
} else {
|
120
|
+
setData({
|
121
|
+
...data,
|
122
|
+
model: {
|
123
|
+
...data.model,
|
124
|
+
objective: data.model.task === 'classification' ? 'binary:logistic' : 'reg:squarederror'
|
125
|
+
}
|
126
|
+
});
|
127
|
+
}
|
128
|
+
}, [data.model.task]);
|
129
|
+
|
130
|
+
useEffect(() => {
|
131
|
+
if (isDataSet) {
|
132
|
+
save();
|
133
|
+
setIsDataSet(false); // Reset the flag
|
134
|
+
}
|
135
|
+
}, [isDataSet]);
|
136
|
+
|
137
|
+
const handleScheduleSave = (scheduleData: any) => {
|
138
|
+
setData({
|
139
|
+
...data,
|
140
|
+
model: {
|
141
|
+
...data.model,
|
142
|
+
retraining_job_attributes: scheduleData.retraining_job_attributes
|
143
|
+
}
|
144
|
+
});
|
145
|
+
setIsDataSet(true);
|
146
|
+
};
|
147
|
+
|
148
|
+
const save = () => {
|
149
|
+
if (data.model.retraining_job_attributes) {
|
150
|
+
const at: any = { hour: data.model.retraining_job_attributes.at.hour };
|
151
|
+
|
152
|
+
// Only include relevant date attributes based on frequency
|
153
|
+
switch (data.model.retraining_job_attributes.frequency) {
|
154
|
+
case 'day':
|
155
|
+
// For daily frequency, only include hour
|
156
|
+
break;
|
157
|
+
case 'week':
|
158
|
+
// For weekly frequency, include hour and day_of_week
|
159
|
+
at.day_of_week = data.model.retraining_job_attributes.at.day_of_week;
|
160
|
+
break;
|
161
|
+
case 'month':
|
162
|
+
// For monthly frequency, include hour and day_of_month
|
163
|
+
at.day_of_month = data.model.retraining_job_attributes.at.day_of_month;
|
164
|
+
break;
|
165
|
+
}
|
166
|
+
|
167
|
+
// Update the form data with the cleaned at object
|
168
|
+
setData('model.retraining_job_attributes.at', at);
|
169
|
+
}
|
170
|
+
|
171
|
+
if (data.model.id) {
|
172
|
+
patch(`${rootPath}/models/${data.model.id}`, {
|
173
|
+
onSuccess: () => {
|
174
|
+
router.visit(`${rootPath}/models`);
|
175
|
+
},
|
176
|
+
});
|
177
|
+
} else {
|
178
|
+
post(`${rootPath}/models`, {
|
179
|
+
onSuccess: () => {
|
180
|
+
router.visit(`${rootPath}/models`);
|
181
|
+
},
|
182
|
+
});
|
183
|
+
}
|
184
|
+
}
|
185
|
+
|
186
|
+
const handleSubmit = (e: React.FormEvent) => {
|
187
|
+
e.preventDefault();
|
188
|
+
save();
|
189
|
+
};
|
190
|
+
|
191
|
+
console.log(data.model)
|
192
|
+
const selectedDataset = datasets.find(d => d.id === data.model.dataset_id);
|
193
|
+
|
194
|
+
const filteredTunerJobConstants = constants.tuner_job_constants[data.model.model_type] || {};
|
195
|
+
|
196
|
+
return (
|
197
|
+
<form onSubmit={handleSubmit} className="space-y-8">
|
198
|
+
<div className="flex justify-between items-center border-b pb-4">
|
199
|
+
<h3 className="text-lg font-medium text-gray-900">Model Configuration</h3>
|
200
|
+
<button
|
201
|
+
type="button"
|
202
|
+
onClick={() => setShowScheduleModal(true)}
|
203
|
+
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
204
|
+
>
|
205
|
+
<TrainFront className="w-4 h-4" />
|
206
|
+
Configure Training
|
207
|
+
</button>
|
208
|
+
</div>
|
209
|
+
|
210
|
+
<div className="space-y-6">
|
211
|
+
<div className="grid grid-cols-2 gap-6">
|
212
|
+
<div>
|
213
|
+
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
214
|
+
Model Name
|
215
|
+
</label>
|
216
|
+
<input
|
217
|
+
type="text"
|
218
|
+
id="name"
|
219
|
+
value={data.model.name}
|
220
|
+
onChange={(e) => setData('model.name', e.target.value)}
|
221
|
+
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 px-4 shadow-sm border-gray-300 border"
|
222
|
+
/>
|
223
|
+
<ErrorDisplay error={errors.name} />
|
224
|
+
</div>
|
225
|
+
|
226
|
+
<div>
|
227
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
228
|
+
Model Type
|
229
|
+
</label>
|
230
|
+
<SearchableSelect
|
231
|
+
options={[{ value: 'xgboost', label: 'XGBoost', description: 'Gradient boosting framework' }]}
|
232
|
+
value={data.model.model_type}
|
233
|
+
onChange={(value) => setData('model.model_type', value as string)}
|
234
|
+
placeholder="Select model type"
|
235
|
+
/>
|
236
|
+
<ErrorDisplay error={errors.model_type} />
|
237
|
+
</div>
|
238
|
+
|
239
|
+
<div>
|
240
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
241
|
+
Dataset
|
242
|
+
</label>
|
243
|
+
{isEditing ? (
|
244
|
+
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded-md border border-gray-200">
|
245
|
+
<Lock className="w-4 h-4 text-gray-400" />
|
246
|
+
<span className="text-gray-700">{selectedDataset?.name}</span>
|
247
|
+
</div>
|
248
|
+
) : (
|
249
|
+
<SearchableSelect
|
250
|
+
options={datasets.map(dataset => ({
|
251
|
+
value: dataset.id,
|
252
|
+
label: dataset.name,
|
253
|
+
description: `${dataset.num_rows.toLocaleString()} rows`
|
254
|
+
}))}
|
255
|
+
value={data.model.dataset_id}
|
256
|
+
onChange={(value) => setData('model.dataset_id', value)}
|
257
|
+
placeholder="Select dataset"
|
258
|
+
/>
|
259
|
+
)}
|
260
|
+
<ErrorDisplay error={errors.dataset_id} />
|
261
|
+
</div>
|
262
|
+
|
263
|
+
<div>
|
264
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
265
|
+
Task
|
266
|
+
</label>
|
267
|
+
<SearchableSelect
|
268
|
+
options={constants.tasks}
|
269
|
+
value={data.model.task}
|
270
|
+
onChange={(value) => setData('model.task', value as string)}
|
271
|
+
placeholder="Select task"
|
272
|
+
/>
|
273
|
+
<ErrorDisplay error={errors.task} />
|
274
|
+
</div>
|
275
|
+
|
276
|
+
<div>
|
277
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
278
|
+
Objective
|
279
|
+
</label>
|
280
|
+
<SearchableSelect
|
281
|
+
options={objectives || []}
|
282
|
+
value={data.model.objective}
|
283
|
+
onChange={(value) => setData('model.objective', value as string)}
|
284
|
+
placeholder="Select objective"
|
285
|
+
/>
|
286
|
+
<ErrorDisplay error={errors.objective} />
|
287
|
+
</div>
|
288
|
+
</div>
|
289
|
+
|
290
|
+
<div>
|
291
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
292
|
+
Metrics
|
293
|
+
</label>
|
294
|
+
<div className="grid grid-cols-2 gap-4">
|
295
|
+
{constants.metrics[data.model.task]?.map(metric => (
|
296
|
+
<label
|
297
|
+
key={metric.value}
|
298
|
+
className="relative flex items-center px-4 py-3 bg-white border rounded-lg hover:bg-gray-50 cursor-pointer"
|
299
|
+
>
|
300
|
+
<input
|
301
|
+
type="checkbox"
|
302
|
+
checked={data.model.metrics.includes(metric.value)}
|
303
|
+
onChange={(e) => {
|
304
|
+
const metrics = e.target.checked
|
305
|
+
? [...data.model.metrics, metric.value]
|
306
|
+
: data.model.metrics.filter(m => m !== metric.value);
|
307
|
+
setData('model.metrics', metrics);
|
308
|
+
}}
|
309
|
+
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
310
|
+
/>
|
311
|
+
<div className="ml-3">
|
312
|
+
<span className="block text-sm font-medium text-gray-900">
|
313
|
+
{metric.label}
|
314
|
+
</span>
|
315
|
+
<span className="block text-xs text-gray-500">
|
316
|
+
{metric.direction === 'maximize' ? 'Higher is better' : 'Lower is better'}
|
317
|
+
</span>
|
318
|
+
</div>
|
319
|
+
</label>
|
320
|
+
))}
|
321
|
+
</div>
|
322
|
+
</div>
|
323
|
+
</div>
|
324
|
+
|
325
|
+
{data.model.retraining_job_attributes && data.model.retraining_job_attributes.batch_mode && (
|
326
|
+
<>
|
327
|
+
<div className="mt-4">
|
328
|
+
<label className="block text-sm font-medium text-gray-700">
|
329
|
+
Batch Key
|
330
|
+
</label>
|
331
|
+
<SearchableSelect
|
332
|
+
value={data.model.retraining_job_attributes.batch_key || ''}
|
333
|
+
onChange={(value) => setData('model', {
|
334
|
+
...data.model,
|
335
|
+
retraining_job_attributes: {
|
336
|
+
...data.model.retraining_job_attributes,
|
337
|
+
batch_key: value
|
338
|
+
}
|
339
|
+
})}
|
340
|
+
options={selectedDataset?.columns?.map(column => ({
|
341
|
+
value: column.name,
|
342
|
+
label: column.name
|
343
|
+
})) || []}
|
344
|
+
placeholder="Select a column for batch key"
|
345
|
+
/>
|
346
|
+
<ErrorDisplay error={errors['model.retraining_job_attributes.batch_key']} />
|
347
|
+
</div>
|
348
|
+
</>
|
349
|
+
)}
|
350
|
+
|
351
|
+
<div className="flex justify-end gap-3 pt-4 border-t">
|
352
|
+
<button
|
353
|
+
type="button"
|
354
|
+
onClick={() => router.visit(`${rootPath}/models`)}
|
355
|
+
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900"
|
356
|
+
>
|
357
|
+
Cancel
|
358
|
+
</button>
|
359
|
+
<button
|
360
|
+
type="submit"
|
361
|
+
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
362
|
+
>
|
363
|
+
{isEditing ? 'Save Changes' : 'Create Model'}
|
364
|
+
</button>
|
365
|
+
</div>
|
366
|
+
|
367
|
+
<ScheduleModal
|
368
|
+
isOpen={showScheduleModal}
|
369
|
+
onClose={() => setShowScheduleModal(false)}
|
370
|
+
onSave={handleScheduleSave}
|
371
|
+
initialData={{
|
372
|
+
task: data.model.task,
|
373
|
+
metrics: data.model.metrics,
|
374
|
+
modelType: data.model.model_type,
|
375
|
+
dataset: selectedDataset,
|
376
|
+
retraining_job: data.model.retraining_job_attributes
|
377
|
+
}}
|
378
|
+
tunerJobConstants={filteredTunerJobConstants}
|
379
|
+
timezone={constants.timezone}
|
380
|
+
retrainingJobConstants={constants.retraining_job_constants}
|
381
|
+
/>
|
382
|
+
</form>
|
383
|
+
);
|
384
|
+
}
|
@@ -0,0 +1,300 @@
|
|
1
|
+
import React, { useState } from 'react';
|
2
|
+
import { AlertContainer } from './AlertProvider';
|
3
|
+
import { Link, router, usePage } from "@inertiajs/react";
|
4
|
+
import { Brain, Database, HardDrive, ChevronRight, ChevronDown, Menu, Settings2 } from 'lucide-react';
|
5
|
+
import { ScrollArea } from './ui/scroll-area';
|
6
|
+
import { Separator } from './ui/separator';
|
7
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
8
|
+
import { cn } from '@/lib/utils';
|
9
|
+
import { mockDatasets, mockModels } from '../mockData';
|
10
|
+
|
11
|
+
export function NavLink({
|
12
|
+
href,
|
13
|
+
className = (isActive: boolean) => "", // Add type annotation for isActive
|
14
|
+
activeClassName = 'active',
|
15
|
+
children,
|
16
|
+
...props
|
17
|
+
}: {
|
18
|
+
href: string;
|
19
|
+
className?: (isActive: boolean) => string;
|
20
|
+
activeClassName?: string;
|
21
|
+
children: React.ReactNode;
|
22
|
+
[key: string]: any;
|
23
|
+
}) {
|
24
|
+
// Get the current URL path from Inertia's page object
|
25
|
+
const { rootPath, url } = usePage().props;
|
26
|
+
|
27
|
+
// Check if the current URL matches the `href` to apply the active class
|
28
|
+
const isActive = url === href;
|
29
|
+
let classes = className(isActive);
|
30
|
+
|
31
|
+
return (
|
32
|
+
<Link
|
33
|
+
href={`${rootPath}${href}`}
|
34
|
+
className={cn(classes, isActive && activeClassName)}
|
35
|
+
{...props}
|
36
|
+
>
|
37
|
+
{children}
|
38
|
+
</Link>
|
39
|
+
);
|
40
|
+
}
|
41
|
+
|
42
|
+
interface NavItem {
|
43
|
+
title: string;
|
44
|
+
icon: React.ElementType;
|
45
|
+
href: string;
|
46
|
+
children?: NavItem[];
|
47
|
+
}
|
48
|
+
|
49
|
+
const navItems: NavItem[] = [
|
50
|
+
{
|
51
|
+
title: 'Models',
|
52
|
+
icon: Brain,
|
53
|
+
href: '/',
|
54
|
+
children: [
|
55
|
+
{ title: 'All Models', icon: Brain, href: '/models' },
|
56
|
+
{ title: 'New Model', icon: Brain, href: '/models/new' }
|
57
|
+
]
|
58
|
+
},
|
59
|
+
{
|
60
|
+
title: 'Datasources',
|
61
|
+
icon: HardDrive,
|
62
|
+
href: '/datasources',
|
63
|
+
children: [
|
64
|
+
{ title: 'All Datasources', icon: HardDrive, href: '/datasources' },
|
65
|
+
{ title: 'New Datasource', icon: HardDrive, href: '/datasources/new' }
|
66
|
+
]
|
67
|
+
},
|
68
|
+
{
|
69
|
+
title: 'Datasets',
|
70
|
+
icon: Database,
|
71
|
+
href: '/datasets',
|
72
|
+
children: [
|
73
|
+
{ title: 'All Datasets', icon: Database, href: '/datasets' },
|
74
|
+
{ title: 'New Dataset', icon: Database, href: '/datasets/new' }
|
75
|
+
]
|
76
|
+
}
|
77
|
+
];
|
78
|
+
|
79
|
+
function getBreadcrumbs(pathname: string): { title: string; href: string }[] {
|
80
|
+
const { rootPath } = usePage().props; // Inject rootPath
|
81
|
+
const paths = pathname.split('/').filter(Boolean);
|
82
|
+
const breadcrumbs = [];
|
83
|
+
let currentPath = rootPath; // Start with rootPath
|
84
|
+
|
85
|
+
// Determine the root breadcrumb based on the first path segment
|
86
|
+
if (paths.length === 0) {
|
87
|
+
return [];
|
88
|
+
}
|
89
|
+
|
90
|
+
let firstSegment;
|
91
|
+
let rootCrumb;
|
92
|
+
if (['datasources', 'datasets', 'models', 'settings'].includes(paths[0])) {
|
93
|
+
firstSegment = paths[0];
|
94
|
+
rootCrumb = 0;
|
95
|
+
} else {
|
96
|
+
firstSegment = paths[1];
|
97
|
+
rootCrumb = 1;
|
98
|
+
}
|
99
|
+
switch (firstSegment) {
|
100
|
+
case 'models':
|
101
|
+
breadcrumbs.push({ title: 'Models', href: `${rootPath}/models` });
|
102
|
+
break;
|
103
|
+
case 'datasources':
|
104
|
+
breadcrumbs.push({ title: 'Datasources', href: `${rootPath}/datasources` });
|
105
|
+
break;
|
106
|
+
case 'datasets':
|
107
|
+
breadcrumbs.push({ title: 'Datasets', href: `${rootPath}/datasets` });
|
108
|
+
break;
|
109
|
+
case 'settings':
|
110
|
+
breadcrumbs.push({ title: 'Settings', href: `${rootPath}/settings` });
|
111
|
+
break;
|
112
|
+
default:
|
113
|
+
breadcrumbs.push({ title: 'Models', href: `${rootPath}/models` });
|
114
|
+
}
|
115
|
+
|
116
|
+
// Add remaining breadcrumbs only if there are more segments
|
117
|
+
for (let i = rootCrumb + 1; i < paths.length; i++) {
|
118
|
+
const path = paths[i];
|
119
|
+
currentPath += `/${paths[i]}`;
|
120
|
+
|
121
|
+
// Handle special cases for IDs
|
122
|
+
if (paths[i-1] === 'datasets' && path !== 'new') {
|
123
|
+
breadcrumbs.push({
|
124
|
+
title: 'Details',
|
125
|
+
href: currentPath
|
126
|
+
});
|
127
|
+
} else if (paths[i-1] === 'models' && path !== 'new') {
|
128
|
+
breadcrumbs.push({
|
129
|
+
title: 'Details',
|
130
|
+
href: currentPath
|
131
|
+
});
|
132
|
+
} else {
|
133
|
+
const title = path === 'new'
|
134
|
+
? 'New'
|
135
|
+
: path === 'edit'
|
136
|
+
? 'Edit'
|
137
|
+
: path.charAt(0).toUpperCase() + path.slice(1);
|
138
|
+
breadcrumbs.push({ title, href: currentPath });
|
139
|
+
}
|
140
|
+
}
|
141
|
+
|
142
|
+
return breadcrumbs;
|
143
|
+
}
|
144
|
+
|
145
|
+
interface NavigationProps {
|
146
|
+
children: React.ReactNode;
|
147
|
+
}
|
148
|
+
|
149
|
+
export function Navigation({ children }: NavigationProps) {
|
150
|
+
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
151
|
+
const [openSections, setOpenSections] = useState<string[]>(['Models']);
|
152
|
+
const breadcrumbs = getBreadcrumbs(location.pathname);
|
153
|
+
|
154
|
+
const toggleSection = (title: string) => {
|
155
|
+
setOpenSections(prev =>
|
156
|
+
prev.includes(title)
|
157
|
+
? prev.filter(t => t !== title)
|
158
|
+
: [...prev, title]
|
159
|
+
);
|
160
|
+
};
|
161
|
+
|
162
|
+
return (
|
163
|
+
<div className="min-h-screen bg-gray-50">
|
164
|
+
{/* Sidebar */}
|
165
|
+
<div
|
166
|
+
className={cn(
|
167
|
+
"fixed left-0 top-0 z-40 h-screen bg-white border-r transition-all duration-300",
|
168
|
+
isSidebarOpen ? "w-64" : "w-16"
|
169
|
+
)}
|
170
|
+
>
|
171
|
+
<div className="flex h-16 items-center border-b px-4">
|
172
|
+
{isSidebarOpen ? (
|
173
|
+
<>
|
174
|
+
<Brain className="w-8 h-8 text-blue-600" />
|
175
|
+
<h1 className="text-xl font-bold text-gray-900 ml-2">EasyML</h1>
|
176
|
+
</>
|
177
|
+
) : (
|
178
|
+
<Brain className="w-8 h-8 text-blue-600" />
|
179
|
+
)}
|
180
|
+
<button
|
181
|
+
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
182
|
+
className="ml-auto p-2 hover:bg-gray-100 rounded-md"
|
183
|
+
>
|
184
|
+
<Menu className="w-4 h-4" />
|
185
|
+
</button>
|
186
|
+
</div>
|
187
|
+
|
188
|
+
<ScrollArea className="h-[calc(100vh-4rem)] px-3">
|
189
|
+
<div className="space-y-2 py-4">
|
190
|
+
{navItems.map((section) => (
|
191
|
+
<Collapsible
|
192
|
+
key={section.title}
|
193
|
+
open={openSections.includes(section.title)}
|
194
|
+
onOpenChange={() => toggleSection(section.title)}
|
195
|
+
>
|
196
|
+
<CollapsibleTrigger className="flex items-center w-full p-2 hover:bg-gray-100 rounded-md">
|
197
|
+
<section.icon className="w-4 h-4" />
|
198
|
+
{isSidebarOpen && (
|
199
|
+
<>
|
200
|
+
<span className="ml-2 text-sm font-medium flex-1 text-left">
|
201
|
+
{section.title}
|
202
|
+
</span>
|
203
|
+
{openSections.includes(section.title) ? (
|
204
|
+
<ChevronDown className="w-4 h-4" />
|
205
|
+
) : (
|
206
|
+
<ChevronRight className="w-4 h-4" />
|
207
|
+
)}
|
208
|
+
</>
|
209
|
+
)}
|
210
|
+
</CollapsibleTrigger>
|
211
|
+
<CollapsibleContent>
|
212
|
+
{isSidebarOpen && section.children?.map((item) => (
|
213
|
+
<NavLink
|
214
|
+
key={item.href}
|
215
|
+
href={item.href}
|
216
|
+
className={({ isActive }) =>
|
217
|
+
cn(
|
218
|
+
"flex items-center pl-8 pr-2 py-2 text-sm rounded-md",
|
219
|
+
isActive
|
220
|
+
? "bg-blue-50 text-blue-600"
|
221
|
+
: "text-gray-600 hover:bg-gray-50"
|
222
|
+
)
|
223
|
+
}
|
224
|
+
>
|
225
|
+
<item.icon className="w-4 h-4" />
|
226
|
+
<span className="ml-2">{item.title}</span>
|
227
|
+
</NavLink>
|
228
|
+
))}
|
229
|
+
</CollapsibleContent>
|
230
|
+
</Collapsible>
|
231
|
+
))}
|
232
|
+
|
233
|
+
<Separator className="my-4" />
|
234
|
+
|
235
|
+
{/* Settings Link */}
|
236
|
+
<NavLink
|
237
|
+
href="/settings"
|
238
|
+
className={({ isActive }) =>
|
239
|
+
cn(
|
240
|
+
"flex items-center w-full p-2 rounded-md",
|
241
|
+
isActive
|
242
|
+
? "bg-blue-50 text-blue-600"
|
243
|
+
: "text-gray-600 hover:bg-gray-50"
|
244
|
+
)
|
245
|
+
}
|
246
|
+
>
|
247
|
+
<Settings2 className="w-4 h-4" />
|
248
|
+
{isSidebarOpen && (
|
249
|
+
<span className="ml-2 text-sm font-medium">Settings</span>
|
250
|
+
)}
|
251
|
+
</NavLink>
|
252
|
+
</div>
|
253
|
+
</ScrollArea>
|
254
|
+
</div>
|
255
|
+
|
256
|
+
{/* Main content */}
|
257
|
+
<div
|
258
|
+
className={cn(
|
259
|
+
"transition-all duration-300",
|
260
|
+
isSidebarOpen ? "ml-64" : "ml-16"
|
261
|
+
)}
|
262
|
+
>
|
263
|
+
<AlertContainer />
|
264
|
+
|
265
|
+
{/* Breadcrumbs */}
|
266
|
+
<div className="h-16 border-b bg-white flex items-center px-4">
|
267
|
+
<nav className="flex" aria-label="Breadcrumb">
|
268
|
+
<ol className="flex items-center space-x-2">
|
269
|
+
{breadcrumbs.map((crumb, index) => (
|
270
|
+
<React.Fragment key={crumb.href}>
|
271
|
+
{index > 0 && (
|
272
|
+
<ChevronRight className="w-4 h-4 text-gray-400" />
|
273
|
+
)}
|
274
|
+
<li>
|
275
|
+
<Link
|
276
|
+
href={crumb.href}
|
277
|
+
className={cn(
|
278
|
+
"text-sm",
|
279
|
+
index === breadcrumbs.length - 1
|
280
|
+
? "text-blue-600 font-medium"
|
281
|
+
: "text-gray-500 hover:text-gray-700"
|
282
|
+
)}
|
283
|
+
>
|
284
|
+
{crumb.title}
|
285
|
+
</Link>
|
286
|
+
</li>
|
287
|
+
</React.Fragment>
|
288
|
+
))}
|
289
|
+
</ol>
|
290
|
+
</nav>
|
291
|
+
</div>
|
292
|
+
|
293
|
+
{/* Page content */}
|
294
|
+
<main className="min-h-[calc(100vh-4rem)]">
|
295
|
+
{children}
|
296
|
+
</main>
|
297
|
+
</div>
|
298
|
+
</div>
|
299
|
+
);
|
300
|
+
}
|