easy_ml 0.2.0.pre.rc71 → 0.2.0.pre.rc75

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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/easy_ml/datasets_controller.rb +33 -0
  3. data/app/controllers/easy_ml/datasources_controller.rb +7 -0
  4. data/app/controllers/easy_ml/models_controller.rb +46 -0
  5. data/app/frontend/components/DatasetCard.tsx +212 -0
  6. data/app/frontend/components/ModelCard.tsx +114 -29
  7. data/app/frontend/components/StackTrace.tsx +13 -0
  8. data/app/frontend/components/dataset/FeatureConfigPopover.tsx +10 -7
  9. data/app/frontend/components/datasets/UploadDatasetButton.tsx +51 -0
  10. data/app/frontend/components/models/DownloadModelModal.tsx +90 -0
  11. data/app/frontend/components/models/UploadModelModal.tsx +212 -0
  12. data/app/frontend/components/models/index.ts +2 -0
  13. data/app/frontend/pages/DatasetsPage.tsx +36 -130
  14. data/app/frontend/pages/DatasourcesPage.tsx +22 -2
  15. data/app/frontend/pages/ModelsPage.tsx +37 -11
  16. data/app/frontend/types/dataset.ts +1 -2
  17. data/app/frontend/types.ts +1 -1
  18. data/app/jobs/easy_ml/reaper.rb +55 -0
  19. data/app/jobs/easy_ml/training_job.rb +1 -1
  20. data/app/models/easy_ml/column/imputers/base.rb +4 -0
  21. data/app/models/easy_ml/column/imputers/clip.rb +5 -3
  22. data/app/models/easy_ml/column/imputers/imputer.rb +11 -13
  23. data/app/models/easy_ml/column/imputers/mean.rb +7 -3
  24. data/app/models/easy_ml/column/imputers/null_imputer.rb +3 -0
  25. data/app/models/easy_ml/column/imputers/ordinal_encoder.rb +5 -1
  26. data/app/models/easy_ml/column/imputers.rb +3 -1
  27. data/app/models/easy_ml/column/lineage/base.rb +5 -1
  28. data/app/models/easy_ml/column/lineage/computed_by_feature.rb +1 -1
  29. data/app/models/easy_ml/column/lineage/preprocessed.rb +1 -1
  30. data/app/models/easy_ml/column/lineage/raw_dataset.rb +1 -1
  31. data/app/models/easy_ml/column/selector.rb +4 -0
  32. data/app/models/easy_ml/column.rb +79 -63
  33. data/app/models/easy_ml/column_history.rb +28 -28
  34. data/app/models/easy_ml/column_list/imputer.rb +23 -0
  35. data/app/models/easy_ml/column_list.rb +39 -26
  36. data/app/models/easy_ml/dataset/learner/base.rb +34 -0
  37. data/app/models/easy_ml/dataset/learner/eager/boolean.rb +10 -0
  38. data/app/models/easy_ml/dataset/learner/eager/categorical.rb +51 -0
  39. data/app/models/easy_ml/dataset/learner/eager/query.rb +37 -0
  40. data/app/models/easy_ml/dataset/learner/eager.rb +43 -0
  41. data/app/models/easy_ml/dataset/learner/lazy/boolean.rb +13 -0
  42. data/app/models/easy_ml/dataset/learner/lazy/categorical.rb +10 -0
  43. data/app/models/easy_ml/dataset/learner/lazy/datetime.rb +19 -0
  44. data/app/models/easy_ml/dataset/learner/lazy/null.rb +17 -0
  45. data/app/models/easy_ml/dataset/learner/lazy/numeric.rb +19 -0
  46. data/app/models/easy_ml/dataset/learner/lazy/query.rb +69 -0
  47. data/app/models/easy_ml/dataset/learner/lazy/string.rb +19 -0
  48. data/app/models/easy_ml/dataset/learner/lazy.rb +51 -0
  49. data/app/models/easy_ml/dataset/learner/query.rb +25 -0
  50. data/app/models/easy_ml/dataset/learner.rb +100 -0
  51. data/app/models/easy_ml/dataset.rb +150 -36
  52. data/app/models/easy_ml/dataset_history.rb +1 -0
  53. data/app/models/easy_ml/datasource.rb +9 -0
  54. data/app/models/easy_ml/event.rb +5 -7
  55. data/app/models/easy_ml/export/column.rb +27 -0
  56. data/app/models/easy_ml/export/dataset.rb +37 -0
  57. data/app/models/easy_ml/export/datasource.rb +12 -0
  58. data/app/models/easy_ml/export/feature.rb +24 -0
  59. data/app/models/easy_ml/export/model.rb +40 -0
  60. data/app/models/easy_ml/export/retraining_job.rb +20 -0
  61. data/app/models/easy_ml/export/splitter.rb +14 -0
  62. data/app/models/easy_ml/feature.rb +21 -0
  63. data/app/models/easy_ml/import/column.rb +35 -0
  64. data/app/models/easy_ml/import/dataset.rb +148 -0
  65. data/app/models/easy_ml/import/feature.rb +36 -0
  66. data/app/models/easy_ml/import/model.rb +136 -0
  67. data/app/models/easy_ml/import/retraining_job.rb +29 -0
  68. data/app/models/easy_ml/import/splitter.rb +34 -0
  69. data/app/models/easy_ml/lineage.rb +44 -0
  70. data/app/models/easy_ml/model.rb +101 -37
  71. data/app/models/easy_ml/model_file.rb +6 -0
  72. data/app/models/easy_ml/models/xgboost/evals_callback.rb +7 -7
  73. data/app/models/easy_ml/models/xgboost.rb +33 -9
  74. data/app/models/easy_ml/retraining_job.rb +8 -1
  75. data/app/models/easy_ml/retraining_run.rb +7 -5
  76. data/app/models/easy_ml/splitter.rb +8 -0
  77. data/app/models/lineage_history.rb +6 -0
  78. data/app/serializers/easy_ml/column_serializer.rb +7 -1
  79. data/app/serializers/easy_ml/dataset_serializer.rb +2 -1
  80. data/app/serializers/easy_ml/lineage_serializer.rb +9 -0
  81. data/config/routes.rb +14 -1
  82. data/lib/easy_ml/core/tuner/adapters/base_adapter.rb +3 -3
  83. data/lib/easy_ml/core/tuner.rb +13 -12
  84. data/lib/easy_ml/data/polars_column.rb +149 -100
  85. data/lib/easy_ml/data/polars_reader.rb +8 -5
  86. data/lib/easy_ml/data/polars_schema.rb +56 -0
  87. data/lib/easy_ml/data/splits/file_split.rb +20 -2
  88. data/lib/easy_ml/data/splits/split.rb +10 -1
  89. data/lib/easy_ml/data.rb +1 -0
  90. data/lib/easy_ml/deep_compact.rb +19 -0
  91. data/lib/easy_ml/engine.rb +1 -0
  92. data/lib/easy_ml/feature_store.rb +2 -6
  93. data/lib/easy_ml/railtie/generators/migration/migration_generator.rb +6 -0
  94. data/lib/easy_ml/railtie/templates/migration/add_extra_metadata_to_columns.rb.tt +9 -0
  95. data/lib/easy_ml/railtie/templates/migration/add_raw_schema_to_datasets.rb.tt +9 -0
  96. data/lib/easy_ml/railtie/templates/migration/add_unique_constraint_to_easy_ml_model_names.rb.tt +8 -0
  97. data/lib/easy_ml/railtie/templates/migration/create_easy_ml_lineages.rb.tt +24 -0
  98. data/lib/easy_ml/railtie/templates/migration/remove_evaluator_from_retraining_jobs.rb.tt +7 -0
  99. data/lib/easy_ml/railtie/templates/migration/update_preprocessing_steps_to_jsonb.rb.tt +18 -0
  100. data/lib/easy_ml/timing.rb +34 -0
  101. data/lib/easy_ml/version.rb +1 -1
  102. data/lib/easy_ml.rb +2 -0
  103. data/public/easy_ml/assets/.vite/manifest.json +2 -2
  104. data/public/easy_ml/assets/assets/Application-Q7L6ioxr.css +1 -0
  105. data/public/easy_ml/assets/assets/entrypoints/Application.tsx-Rrzo4ecT.js +522 -0
  106. data/public/easy_ml/assets/assets/entrypoints/Application.tsx-Rrzo4ecT.js.map +1 -0
  107. metadata +53 -12
  108. data/app/models/easy_ml/column/learners/base.rb +0 -103
  109. data/app/models/easy_ml/column/learners/boolean.rb +0 -11
  110. data/app/models/easy_ml/column/learners/categorical.rb +0 -51
  111. data/app/models/easy_ml/column/learners/datetime.rb +0 -19
  112. data/app/models/easy_ml/column/learners/null.rb +0 -22
  113. data/app/models/easy_ml/column/learners/numeric.rb +0 -33
  114. data/app/models/easy_ml/column/learners/string.rb +0 -15
  115. data/public/easy_ml/assets/assets/Application-BbFobaXt.css +0 -1
  116. data/public/easy_ml/assets/assets/entrypoints/Application.tsx-CibZcrBc.js +0 -489
  117. data/public/easy_ml/assets/assets/entrypoints/Application.tsx-CibZcrBc.js.map +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f0b198fdf362bfbeecaf470f68c3569fa7acee3497b215365c34edfe3ad85a9
4
- data.tar.gz: 35157026161ddc0971d7facb2f4a2420580c950579a21d87e53e435f12aadf78
3
+ metadata.gz: 9c924f9d9ccee9dcad409f1dd077b55f29214aea419c4edbf3f7f7d16b8d71e6
4
+ data.tar.gz: c82401ebf3763354d77ff536d12d81bc9642ce9145bce961f9c274b535b16eea
5
5
  SHA512:
6
- metadata.gz: 978b74ce4888414cd3a98a1d8b7fc0fd9282421f02ec21f3d60c0d7e2eb1970cbbcd1500420d7684f9e517d3126918b23766f076f45024a2a6d675079d642e3d
7
- data.tar.gz: bf4b621fefeb4cd8d8e436a1cca0af306af6e841141d0386f4fe7d08ef3066a4ecdc1a0696073a3b14a79f5c3a4b9fa68d43bfac4c2189001f0c74ff27038f28
6
+ metadata.gz: 2df9bbd0fd82067fa4ec5976ba5f5292e2c052ff1eeaad0286b44cbfe4797cf02102fba788a01963b413a33a51297c5501f623310461fda18c2f541a61c2172c
7
+ data.tar.gz: 0f131bc4143b76d1b13641716854d1bf7ed0c06751cf31b69ebd2a8f1ec96abb1417d38d72039fd8266619c3183250ef1d0b39dc4b9381fa15843a910271387e
@@ -129,6 +129,39 @@ module EasyML
129
129
  redirect_to easy_ml_datasets_path, notice: "Dataset refresh has been initiated."
130
130
  end
131
131
 
132
+ def abort
133
+ dataset = Dataset.find(params[:id])
134
+ dataset.abort!
135
+
136
+ redirect_to easy_ml_datasets_path, notice: "Dataset processing has been aborted."
137
+ end
138
+
139
+ def download
140
+ dataset = Dataset.find(params[:id])
141
+ config = dataset.to_config
142
+
143
+ send_data JSON.pretty_generate(config),
144
+ filename: "#{dataset.name.parameterize}-config.json",
145
+ type: "application/json",
146
+ disposition: "attachment"
147
+ end
148
+
149
+ def upload
150
+ dataset = Dataset.find(params[:id]) if params[:id].present?
151
+
152
+ begin
153
+ config = JSON.parse(params[:config].read)
154
+ action = dataset.present? ? :update : :create
155
+ EasyML::Dataset.from_config(config, action: action, dataset: dataset)
156
+
157
+ flash[:notice] = "Dataset configuration was successfully uploaded."
158
+ redirect_to easy_ml_datasets_path
159
+ rescue JSON::ParserError, StandardError => e
160
+ flash[:error] = "Failed to upload configuration: #{e.message}"
161
+ redirect_to easy_ml_datasets_path
162
+ end
163
+ end
164
+
132
165
  private
133
166
 
134
167
  def preprocessing_params
@@ -80,6 +80,13 @@ module EasyML
80
80
  redirect_to easy_ml_datasources_path, error: "Datasource not found..."
81
81
  end
82
82
 
83
+ def abort
84
+ datasource = Datasource.find(params[:id])
85
+ datasource.abort!
86
+
87
+ redirect_to easy_ml_datasources_path, notice: "Datasource sync has been aborted."
88
+ end
89
+
83
90
  private
84
91
 
85
92
  def datasource_params
@@ -23,6 +23,7 @@ module EasyML
23
23
 
24
24
  render inertia: "pages/ModelsPage", props: {
25
25
  models: models.map { |model| model_to_json(model) },
26
+ datasets: EasyML::Dataset.all.map { |dataset| dataset.slice(:id, :name, :num_rows) },
26
27
  }
27
28
  end
28
29
 
@@ -118,6 +119,51 @@ module EasyML
118
119
  redirect_to easy_ml_models_path
119
120
  end
120
121
 
122
+ def abort
123
+ model = Model.find(params[:id])
124
+ model.abort!
125
+
126
+ flash[:notice] = "Model training aborted!"
127
+ redirect_to easy_ml_models_path
128
+ end
129
+
130
+ def download
131
+ model = Model.find(params[:id])
132
+ config = model.to_config(include_dataset: params[:include_dataset] == "true")
133
+
134
+ send_data JSON.pretty_generate(config),
135
+ filename: "#{model.name.parameterize}-config.json",
136
+ type: "application/json",
137
+ disposition: "attachment"
138
+ end
139
+
140
+ def upload
141
+ model = Model.find(params[:id]) if params[:id].present?
142
+
143
+ begin
144
+ config = JSON.parse(params[:config].read)
145
+ dataset = if params[:dataset_id].present?
146
+ EasyML::Dataset.find(params[:dataset_id])
147
+ else
148
+ model.dataset
149
+ end
150
+
151
+ action = model.present? ? :update : :create
152
+
153
+ EasyML::Model.from_config(config,
154
+ action: action,
155
+ model: model,
156
+ include_dataset: params[:include_dataset] == "true",
157
+ dataset: dataset)
158
+
159
+ flash[:notice] = "Model configuration was successfully uploaded."
160
+ redirect_to easy_ml_models_path
161
+ rescue JSON::ParserError, StandardError => e
162
+ flash[:error] = "Failed to upload configuration: #{e.message}"
163
+ redirect_to easy_ml_models_path
164
+ end
165
+ end
166
+
121
167
  private
122
168
 
123
169
  def includes_list
@@ -0,0 +1,212 @@
1
+ import React from 'react';
2
+ import { Link } from '@inertiajs/react';
3
+ import { Database, Trash2, ExternalLink, Loader2, AlertCircle, ChevronDown, ChevronUp, RefreshCw, XCircle, Download, Upload } from 'lucide-react';
4
+ import { Dataset, DatasetWorkflowStatus, Column } from "@types/dataset";
5
+ import { StackTrace } from './StackTrace';
6
+
7
+ interface Props {
8
+ dataset: Dataset;
9
+ rootPath: string;
10
+ onDelete: (id: number) => void;
11
+ onRefresh: (id: number) => void;
12
+ onAbort: (id: number) => void;
13
+ isErrorExpanded: boolean;
14
+ onToggleError: (id: number) => void;
15
+ }
16
+
17
+ const STATUS_STYLES: Record<DatasetWorkflowStatus, { bg: string; text: string; icon: React.ReactNode }> = {
18
+ analyzing: {
19
+ bg: 'bg-blue-100',
20
+ text: 'text-blue-800',
21
+ icon: <Loader2 className="w-4 h-4 animate-spin" />
22
+ },
23
+ ready: {
24
+ bg: 'bg-green-100',
25
+ text: 'text-green-800',
26
+ icon: null
27
+ },
28
+ failed: {
29
+ bg: 'bg-red-100',
30
+ text: 'text-red-800',
31
+ icon: <AlertCircle className="w-4 h-4" />
32
+ },
33
+ };
34
+
35
+ export function DatasetCard({
36
+ dataset,
37
+ rootPath,
38
+ onDelete,
39
+ onRefresh,
40
+ onAbort,
41
+ isErrorExpanded,
42
+ onToggleError
43
+ }: Props) {
44
+ // Create a hidden file input for handling uploads
45
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
46
+
47
+ const handleDownload = () => {
48
+ window.location.href = `${rootPath}/datasets/${dataset.id}/download`;
49
+ };
50
+
51
+ const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
52
+ const file = event.target.files?.[0];
53
+ if (!file) return;
54
+
55
+ const formData = new FormData();
56
+ formData.append('config', file);
57
+
58
+ fetch(`${rootPath}/datasets/${dataset.id}/upload`, {
59
+ method: 'POST',
60
+ body: formData,
61
+ credentials: 'same-origin',
62
+ headers: {
63
+ 'X-CSRF-Token': document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content || '',
64
+ },
65
+ }).then(response => {
66
+ if (response.ok) {
67
+ window.location.reload();
68
+ } else {
69
+ console.error('Upload failed');
70
+ }
71
+ });
72
+ };
73
+
74
+ return (
75
+ <div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
76
+ <div className="flex justify-between items-start mb-4">
77
+ <div className="flex items-start gap-3">
78
+ <Database className="w-5 h-5 text-blue-600 mt-1" />
79
+ <div>
80
+ <div className="flex items-center gap-2">
81
+ <h3 className="text-lg font-semibold text-gray-900">
82
+ {dataset.name}
83
+ </h3>
84
+ <div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[dataset.workflow_status].bg} ${STATUS_STYLES[dataset.workflow_status].text}`}>
85
+ {STATUS_STYLES[dataset.workflow_status].icon}
86
+ <span>{dataset.workflow_status.charAt(0).toUpperCase() + dataset.workflow_status.slice(1)}</span>
87
+ </div>
88
+ </div>
89
+ <p className="text-sm text-gray-500 mt-1">
90
+ {dataset.description}
91
+ </p>
92
+ </div>
93
+ </div>
94
+ <div className="flex gap-2">
95
+ <Link
96
+ href={`${rootPath}/datasets/${dataset.id}`}
97
+ className={`transition-colors ${
98
+ dataset.workflow_status === 'analyzing'
99
+ ? 'text-gray-300 cursor-not-allowed pointer-events-none'
100
+ : 'text-gray-400 hover:text-blue-600'
101
+ }`}
102
+ title={dataset.workflow_status === 'analyzing' ? 'Dataset is being analyzed' : 'View details'}
103
+ >
104
+ <ExternalLink className="w-5 h-5" />
105
+ </Link>
106
+ <button
107
+ onClick={() => onRefresh(dataset.id)}
108
+ disabled={dataset.workflow_status === 'analyzing'}
109
+ className={`transition-colors ${
110
+ dataset.workflow_status === 'analyzing'
111
+ ? 'text-gray-300 cursor-not-allowed'
112
+ : 'text-gray-400 hover:text-blue-600'
113
+ }`}
114
+ title={dataset.workflow_status === 'analyzing' ? 'Dataset is being analyzed' : 'Refresh dataset'}
115
+ >
116
+ <RefreshCw className="w-5 h-5" />
117
+ </button>
118
+ {dataset.workflow_status === 'analyzing' && (
119
+ <button
120
+ onClick={() => onAbort(dataset.id)}
121
+ className="text-gray-400 hover:text-red-600 transition-colors"
122
+ title="Abort analysis"
123
+ >
124
+ <XCircle className="w-5 h-5" />
125
+ </button>
126
+ )}
127
+ <button
128
+ onClick={handleDownload}
129
+ className="text-gray-400 hover:text-blue-600 transition-colors"
130
+ title="Download dataset configuration"
131
+ >
132
+ <Download className="w-5 h-5" />
133
+ </button>
134
+ <button
135
+ onClick={() => fileInputRef.current?.click()}
136
+ className="text-gray-400 hover:text-green-600 transition-colors"
137
+ title="Upload dataset configuration"
138
+ >
139
+ <Upload className="w-5 h-5" />
140
+ </button>
141
+ <input
142
+ type="file"
143
+ ref={fileInputRef}
144
+ onChange={handleUpload}
145
+ accept=".json"
146
+ className="hidden"
147
+ />
148
+ <button
149
+ className="text-gray-400 hover:text-red-600 transition-colors"
150
+ title="Delete dataset"
151
+ onClick={() => onDelete(dataset.id)}
152
+ >
153
+ <Trash2 className="w-5 h-5" />
154
+ </button>
155
+ </div>
156
+ </div>
157
+
158
+ <div className="grid grid-cols-2 gap-4 mt-4">
159
+ <div>
160
+ <span className="text-sm text-gray-500">Columns</span>
161
+ <p className="text-sm font-medium text-gray-900">
162
+ {dataset.columns.length} columns
163
+ </p>
164
+ </div>
165
+ <div>
166
+ <span className="text-sm text-gray-500">Rows</span>
167
+ <p className="text-sm font-medium text-gray-900">
168
+ {dataset.num_rows.toLocaleString()}
169
+ </p>
170
+ </div>
171
+ </div>
172
+
173
+ <div className="mt-4 pt-4 border-t border-gray-100">
174
+ <div className="flex flex-wrap gap-2">
175
+ {dataset.columns.slice(0, 3).map((column: Column) => (
176
+ <span
177
+ key={column.name}
178
+ className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
179
+ >
180
+ {column.name}
181
+ </span>
182
+ ))}
183
+ {dataset.columns.length > 3 && (
184
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
185
+ +{dataset.columns.length - 3} more
186
+ </span>
187
+ )}
188
+ </div>
189
+ </div>
190
+
191
+ {dataset.workflow_status === 'failed' && dataset.stacktrace && (
192
+ <div className="mt-4 pt-4 border-t border-gray-100">
193
+ <button
194
+ onClick={() => onToggleError(dataset.id)}
195
+ className="flex items-center gap-2 text-sm text-red-600 hover:text-red-700"
196
+ >
197
+ <AlertCircle className="w-4 h-4" />
198
+ <span>View Error Details</span>
199
+ {isErrorExpanded ? (
200
+ <ChevronUp className="w-4 h-4" />
201
+ ) : (
202
+ <ChevronDown className="w-4 h-4" />
203
+ )}
204
+ </button>
205
+ {isErrorExpanded && (
206
+ <StackTrace stacktrace={dataset.stacktrace} />
207
+ )}
208
+ </div>
209
+ )}
210
+ </div>
211
+ );
212
+ }
@@ -1,20 +1,33 @@
1
1
  import React, { useState, useEffect } from 'react';
2
- import { Activity, Calendar, Database, Settings, ExternalLink, Play, LineChart,
3
- Trash2, Loader2, XCircle, CheckCircle2, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
2
+ import {
3
+ Activity, Calendar, Database, Settings, ExternalLink, Play, LineChart,
4
+ Trash2, Loader2, XCircle, CheckCircle2, AlertCircle, ChevronDown, ChevronUp,
5
+ Download, Upload
6
+ } from 'lucide-react';
4
7
  import { Link, router } from "@inertiajs/react";
5
8
  import { cn } from '@/lib/utils';
6
9
  import type { Model, RetrainingJob, RetrainingRun } from '../types';
7
-
10
+ import { StackTrace } from './StackTrace';
11
+ import { DownloadModelModal, UploadModelModal } from './models';
12
+ import { Dataset } from '../types';
8
13
  interface ModelCardProps {
9
14
  initialModel: Model;
10
15
  onViewDetails: (modelId: number) => void;
11
16
  handleDelete: (modelId: number) => void;
12
17
  rootPath: string;
18
+ datasets: Array<Dataset>;
13
19
  }
14
20
 
15
- export function ModelCard({ initialModel, onViewDetails, handleDelete, rootPath }: ModelCardProps) {
21
+ export function ModelCard({ initialModel, onViewDetails, handleDelete, rootPath, datasets }: ModelCardProps) {
16
22
  const [model, setModel] = useState(initialModel);
17
23
  const [showError, setShowError] = useState(false);
24
+ const [showDownloadModal, setShowDownloadModal] = useState(false);
25
+ const [showUploadModal, setShowUploadModal] = useState(false);
26
+
27
+ // Update local state when initialModel changes
28
+ useEffect(() => {
29
+ setModel(initialModel);
30
+ }, [initialModel]);
18
31
 
19
32
  useEffect(() => {
20
33
  let pollInterval: number | undefined;
@@ -53,6 +66,25 @@ export function ModelCard({ initialModel, onViewDetails, handleDelete, rootPath
53
66
  }
54
67
  };
55
68
 
69
+ const handleAbort = async () => {
70
+ try {
71
+ await router.post(`${rootPath}/models/${model.id}/abort`, {}, {
72
+ preserveScroll: true,
73
+ preserveState: true
74
+ });
75
+ const response = await fetch(`${rootPath}/models/${model.id}`, {
76
+ headers: {
77
+ 'Accept': 'application/json'
78
+ }
79
+ });
80
+ const data = await response.json();
81
+ setModel(data.model);
82
+ } catch (error) {
83
+ console.error('Failed to abort training:', error);
84
+ setShowError(true);
85
+ }
86
+ };
87
+
56
88
  const dataset = model.dataset;
57
89
  const job = model.retraining_job;
58
90
  const lastRun = model.last_run;
@@ -77,6 +109,7 @@ export function ModelCard({ initialModel, onViewDetails, handleDelete, rootPath
77
109
  if (model.is_training) return 'Training in progress...';
78
110
  if (!lastRun) return 'Never trained';
79
111
  if (lastRun.status === 'failed') return 'Last run failed';
112
+ if (lastRun.status === 'aborted') return 'Last run aborted';
80
113
  if (lastRun.status === 'success') {
81
114
  return lastRun.deployable ? 'Last run succeeded' : 'Last run completed (below threshold)';
82
115
  }
@@ -93,12 +126,29 @@ export function ModelCard({ initialModel, onViewDetails, handleDelete, rootPath
93
126
  return 'text-gray-700';
94
127
  };
95
128
 
129
+ const renderTrainingStatus = () => {
130
+ if (model.is_training) {
131
+ return (
132
+ <div className="flex items-center space-x-2">
133
+ <button
134
+ onClick={handleAbort}
135
+ className="text-gray-400 hover:text-red-600"
136
+ title="Abort training"
137
+ >
138
+ <XCircle className="w-5 h-5" />
139
+ </button>
140
+ </div>
141
+ );
142
+ }
143
+ return null;
144
+ };
145
+
96
146
  return (
97
147
  <div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
98
148
  <div className="flex flex-col gap-2">
99
149
  <div className="flex items-center gap-2">
100
150
  <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
101
- ${model.deployment_status === 'inference'
151
+ ${model.deployment_status === 'inference'
102
152
  ? 'bg-blue-100 text-blue-800'
103
153
  : 'bg-gray-100 text-gray-800'}`}
104
154
  >
@@ -118,13 +168,41 @@ export function ModelCard({ initialModel, onViewDetails, handleDelete, rootPath
118
168
  <button
119
169
  onClick={handleTrain}
120
170
  disabled={model.is_training}
121
- className={`text-gray-400 hover:text-green-600 transition-colors ${
122
- model.is_training ? 'opacity-50 cursor-not-allowed' : ''
123
- }`}
171
+ className={`text-gray-400 hover:text-green-600 transition-colors ${model.is_training ? 'opacity-50 cursor-not-allowed' : ''
172
+ }`}
124
173
  title="Train model"
125
174
  >
126
175
  <Play className="w-5 h-5" />
127
176
  </button>
177
+ {renderTrainingStatus()}
178
+ <button
179
+ onClick={() => setShowDownloadModal(true)}
180
+ className="text-gray-400 hover:text-blue-600"
181
+ title="Download configuration"
182
+ >
183
+ <Download className="w-5 h-5" />
184
+ </button>
185
+ <button
186
+ onClick={() => setShowUploadModal(true)}
187
+ className="text-gray-400 hover:text-green-600"
188
+ title="Upload configuration"
189
+ >
190
+ <Upload className="w-5 h-5" />
191
+ </button>
192
+ <Link
193
+ href={`${rootPath}/models/${model.id}/edit`}
194
+ className="text-gray-400 hover:text-gray-600"
195
+ title="Edit model"
196
+ >
197
+ <Settings className="w-5 h-5" />
198
+ </Link>
199
+ <button
200
+ onClick={() => handleDelete(model.id)}
201
+ className="text-gray-400 hover:text-gray-600"
202
+ title="Delete model"
203
+ >
204
+ <Trash2 className="w-5 h-5" />
205
+ </button>
128
206
  {
129
207
  model.metrics_url && (
130
208
  <a
@@ -145,20 +223,6 @@ export function ModelCard({ initialModel, onViewDetails, handleDelete, rootPath
145
223
  >
146
224
  <ExternalLink className="w-5 h-5" />
147
225
  </Link>
148
- <Link
149
- href={`${rootPath}/models/${model.id}/edit`}
150
- className="text-gray-400 hover:text-gray-600"
151
- title="Edit model"
152
- >
153
- <Settings className="w-5 h-5" />
154
- </Link>
155
- <button
156
- onClick={() => handleDelete(model.id)}
157
- className="text-gray-400 hover:text-gray-600"
158
- title="Delete model"
159
- >
160
- <Trash2 className="w-5 h-5" />
161
- </button>
162
226
  </div>
163
227
  </div>
164
228
 
@@ -176,7 +240,7 @@ export function ModelCard({ initialModel, onViewDetails, handleDelete, rootPath
176
240
  <div className="flex items-center gap-2">
177
241
  <Database className="w-4 h-4 text-gray-400" />
178
242
  {dataset ? (
179
- <Link
243
+ <Link
180
244
  href={`${rootPath}/datasets/${dataset.id}`}
181
245
  className="text-sm text-blue-600 hover:text-blue-800"
182
246
  >
@@ -214,11 +278,10 @@ export function ModelCard({ initialModel, onViewDetails, handleDelete, rootPath
214
278
  {Object.entries(lastRun.metrics as Record<string, number>).map(([key, value]) => (
215
279
  <div
216
280
  key={key}
217
- className={`px-2 py-1 rounded-md text-xs font-medium ${
218
- lastRun.deployable
281
+ className={`px-2 py-1 rounded-md text-xs font-medium ${lastRun.deployable
219
282
  ? 'bg-green-100 text-green-800'
220
283
  : 'bg-red-100 text-red-800'
221
- }`}
284
+ }`}
222
285
  >
223
286
  {key}: {value.toFixed(4)}
224
287
  </div>
@@ -243,13 +306,35 @@ export function ModelCard({ initialModel, onViewDetails, handleDelete, rootPath
243
306
  </button>
244
307
  {showError && (
245
308
  <div className="mt-2 p-3 bg-red-50 rounded-md">
246
- <pre className="text-xs text-red-700 whitespace-pre-wrap font-mono">
247
- {lastRun.stacktrace}
248
- </pre>
309
+ <StackTrace stacktrace={lastRun.stacktrace} />
249
310
  </div>
250
311
  )}
251
312
  </div>
252
313
  )}
314
+ {showDownloadModal && (
315
+ <DownloadModelModal
316
+ isOpen={showDownloadModal}
317
+ onClose={() => setShowDownloadModal(false)}
318
+ modelId={model.id}
319
+ />
320
+ )}
321
+
322
+ {showUploadModal && (
323
+ <UploadModelModal
324
+ isOpen={showUploadModal}
325
+ onClose={() => setShowUploadModal(false)}
326
+ modelId={model.id}
327
+ dataset_id={model.dataset_id}
328
+ datasets={datasets}
329
+ />
330
+ )}
331
+ <div className="flex items-center space-x-4">
332
+ <button
333
+ onClick={() => onViewDetails(model.id)}
334
+ className="text-gray-400 hover:text-blue-600"
335
+ >
336
+ </button>
337
+ </div>
253
338
  </div>
254
339
  );
255
340
  }
@@ -0,0 +1,13 @@
1
+ interface StackTraceProps {
2
+ stacktrace: string;
3
+ }
4
+
5
+ export function StackTrace({ stacktrace }: StackTraceProps) {
6
+ return (
7
+ <div className="mt-2 p-3 bg-red-50 rounded-md">
8
+ <pre className="text-xs text-red-700 whitespace-pre-wrap break-words [word-break:break-word] font-mono">
9
+ {stacktrace}
10
+ </pre>
11
+ </div>
12
+ );
13
+ }
@@ -37,16 +37,19 @@ module Features
37
37
  class DidConvert
38
38
  include EasyML::Features
39
39
 
40
- def did_convert(df)
40
+ def computes_columns
41
+ ["did_convert"]
42
+ end
43
+
44
+ def transform(df, feature)
41
45
  df.with_column(
42
- (Polars.col("rev") > 0)
43
- .alias("did_convert")
46
+ (Polars.col("rev") > 0).alias("did_convert")
44
47
  )
45
48
  end
46
-
47
- feature :did_convert,
48
- name: "Did Convert",
49
- description: "Boolean true/false..."
49
+
50
+ feature name: "did_convert",
51
+ description: "Boolean true/false, did the loan application fund?"
52
+
50
53
  end
51
54
  end`}
52
55
  </code>
@@ -0,0 +1,51 @@
1
+ import React, { useRef } from 'react';
2
+ import { Upload } from 'lucide-react';
3
+ import { router, usePage } from '@inertiajs/react';
4
+
5
+ export function UploadDatasetButton() {
6
+ const fileInputRef = useRef<HTMLInputElement>(null);
7
+ const { rootPath } = usePage().props;
8
+
9
+ const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
10
+ const file = event.target.files?.[0];
11
+ if (!file) return;
12
+
13
+ const formData = new FormData();
14
+ formData.append('config', file);
15
+
16
+ fetch(`${rootPath}/datasets/upload`, {
17
+ method: 'POST',
18
+ body: formData,
19
+ credentials: 'same-origin',
20
+ headers: {
21
+ 'X-CSRF-Token': document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content || '',
22
+ },
23
+ }).then(response => {
24
+ if (response.ok) {
25
+ window.location.reload();
26
+ } else {
27
+ console.error('Upload failed');
28
+ }
29
+ });
30
+ };
31
+
32
+ return (
33
+ <>
34
+ <button
35
+ onClick={() => fileInputRef.current?.click()}
36
+ className="inline-flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-50"
37
+ title="Import dataset"
38
+ >
39
+ <Upload className="w-4 h-4" />
40
+ Import
41
+ </button>
42
+ <input
43
+ type="file"
44
+ ref={fileInputRef}
45
+ onChange={handleUpload}
46
+ accept=".json"
47
+ className="hidden"
48
+ />
49
+ </>
50
+ );
51
+ }