From 037ef14f6475c1600cb8d731b157612142e9db9d Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 1 Apr 2026 10:36:58 -0700 Subject: [PATCH 1/5] feat(ui): add project context to processing service details and optional endpoint URL - Add projectId parameter support to useProcessingServiceDetails hook - Pass project context when fetching service details in dialog - Make endpoint_url field optional with updated description for pull-mode services - Fix async service status display logic to check lastSeenLive instead of always returning UNKNOWN Co-Authored-By: Claude --- .../processing-services/useProcessingServiceDetails.ts | 8 +++++--- ui/src/data-services/models/processing-service.ts | 4 +++- .../processing-service-details-dialog.tsx | 2 +- .../details-form/processing-service-details-form.tsx | 6 ++---- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts b/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts index bda9b3c78..15c731cd1 100644 --- a/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts +++ b/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts @@ -10,17 +10,19 @@ const convertServerRecord = (record: ServerProcessingService) => new ProcessingService(record) export const useProcessingServiceDetails = ( - processingServiceId: string + processingServiceId: string, + projectId?: string ): { processingService?: ProcessingService isLoading: boolean isFetching: boolean error?: unknown } => { + const params = projectId ? `?project_id=${projectId}` : '' const { data, isLoading, isFetching, error } = useAuthorizedQuery({ - queryKey: [API_ROUTES.PROCESSING_SERVICES, processingServiceId], - url: `${API_URL}/${API_ROUTES.PROCESSING_SERVICES}/${processingServiceId}`, + queryKey: [API_ROUTES.PROCESSING_SERVICES, processingServiceId, projectId], + url: `${API_URL}/${API_ROUTES.PROCESSING_SERVICES}/${processingServiceId}/${params}`, }) const processingService = useMemo( diff --git a/ui/src/data-services/models/processing-service.ts b/ui/src/data-services/models/processing-service.ts index 627a19616..5ffc67350 100644 --- a/ui/src/data-services/models/processing-service.ts +++ b/ui/src/data-services/models/processing-service.ts @@ -80,7 +80,9 @@ export class ProcessingService extends Entity { color: string } { if (this.isAsync) { - return ProcessingService.getStatusInfo('UNKNOWN') + // Async services derive status from heartbeat + const status_code = this.lastSeenLive ? 'ONLINE' : 'UNKNOWN' + return ProcessingService.getStatusInfo(status_code) } const status_code = this.lastSeenLive ? 'ONLINE' : 'OFFLINE' return ProcessingService.getStatusInfo(status_code) diff --git a/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx b/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx index 895b820c9..41a1dbf09 100644 --- a/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx +++ b/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx @@ -15,7 +15,7 @@ export const ProcessingServiceDetailsDialog = ({ id }: { id: string }) => { const navigate = useNavigate() const { projectId } = useParams() const { processingService, isLoading, error } = - useProcessingServiceDetails(id) + useProcessingServiceDetails(id, projectId) return ( Date: Wed, 1 Apr 2026 12:10:30 -0700 Subject: [PATCH 2/5] fix(ui): reformat with prettier 2.8.4 to match CI Co-Authored-By: Claude --- .../processing-services/useProcessingServiceDetails.ts | 6 +++++- .../processing-service-details-dialog.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts b/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts index 15c731cd1..492585620 100644 --- a/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts +++ b/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts @@ -21,7 +21,11 @@ export const useProcessingServiceDetails = ( const params = projectId ? `?project_id=${projectId}` : '' const { data, isLoading, isFetching, error } = useAuthorizedQuery({ - queryKey: [API_ROUTES.PROCESSING_SERVICES, processingServiceId, projectId], + queryKey: [ + API_ROUTES.PROCESSING_SERVICES, + processingServiceId, + projectId, + ], url: `${API_URL}/${API_ROUTES.PROCESSING_SERVICES}/${processingServiceId}/${params}`, }) diff --git a/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx b/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx index 41a1dbf09..8a49bf280 100644 --- a/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx +++ b/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx @@ -14,8 +14,10 @@ import styles from './styles.module.scss' export const ProcessingServiceDetailsDialog = ({ id }: { id: string }) => { const navigate = useNavigate() const { projectId } = useParams() - const { processingService, isLoading, error } = - useProcessingServiceDetails(id, projectId) + const { processingService, isLoading, error } = useProcessingServiceDetails( + id, + projectId + ) return ( Date: Wed, 1 Apr 2026 12:20:19 -0700 Subject: [PATCH 3/5] fix: normalize empty endpoint_url to null across frontend and backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The form, serializer, and model now all enforce a single convention: endpoint_url is either a valid URL string or NULL, never an empty string. - Frontend: normalize "" → null in form submit handler - Serializer: allow_null=True, allow_blank=False rejects "" at API boundary - Model: save() guard converts "" → None for admin/shell usage - QuerySet: simplified async/sync filters to use isnull only Co-Authored-By: Claude --- ami/ml/models/processing_service.py | 9 ++- ami/ml/serializers.py | 1 + .../reference/react-form-to-drf-values.md | 72 +++++++++++++++++++ .../processing-service-details-form.tsx | 4 +- .../project/entities/details-form/types.ts | 4 +- 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 docs/claude/reference/react-form-to-drf-values.md diff --git a/ami/ml/models/processing_service.py b/ami/ml/models/processing_service.py index bad3dd147..7ca49472c 100644 --- a/ami/ml/models/processing_service.py +++ b/ami/ml/models/processing_service.py @@ -36,7 +36,7 @@ def async_services(self) -> "ProcessingServiceQuerySet": out to them, they poll Antenna for tasks and push results back. Their liveness is tracked via heartbeats from mark_seen() rather than active health checks. """ - return self.filter(models.Q(endpoint_url__isnull=True) | models.Q(endpoint_url__exact="")) + return self.filter(endpoint_url__isnull=True) def sync_services(self) -> "ProcessingServiceQuerySet": """ @@ -46,7 +46,7 @@ def sync_services(self) -> "ProcessingServiceQuerySet": /readyz and /process endpoints. Their liveness is tracked by the periodic check_processing_services_online Celery task. """ - return self.exclude(models.Q(endpoint_url__isnull=True) | models.Q(endpoint_url__exact="")) + return self.filter(endpoint_url__isnull=False) class ProcessingServiceManager(models.Manager.from_queryset(ProcessingServiceQuerySet)): @@ -82,6 +82,11 @@ def is_async(self) -> bool: """ return not self.endpoint_url + def save(self, *args, **kwargs): + if self.endpoint_url == "": + self.endpoint_url = None + super().save(*args, **kwargs) + def __str__(self): endpoint_display = self.endpoint_url or "async" return f'#{self.pk} "{self.name}" ({endpoint_display})' diff --git a/ami/ml/serializers.py b/ami/ml/serializers.py index 1711e19e1..db91b31b1 100644 --- a/ami/ml/serializers.py +++ b/ami/ml/serializers.py @@ -139,6 +139,7 @@ class ProcessingServiceSerializer(DefaultSerializer): pipelines = PipelineNestedSerializer(many=True, read_only=True) projects = serializers.SerializerMethodField() is_async = serializers.BooleanField(read_only=True) + endpoint_url = serializers.CharField(required=False, allow_null=True, allow_blank=False, max_length=1024) project = serializers.PrimaryKeyRelatedField( write_only=True, queryset=Project.objects.all(), diff --git a/docs/claude/reference/react-form-to-drf-values.md b/docs/claude/reference/react-form-to-drf-values.md new file mode 100644 index 000000000..9b861535e --- /dev/null +++ b/docs/claude/reference/react-form-to-drf-values.md @@ -0,0 +1,72 @@ +# React Form Values → DRF Serializer Behavior + +How different form values travel from React Hook Form through the API to Django REST Framework serializers and into the database. + +## Value mapping for a CharField(null=True, blank=True) + +| React form state | JSON sent | DRF `serializer.validated_data` | DB stores | +|---|---|---|---| +| field omitted / `undefined` | key absent | field uses its default (usually `""`) | `""` | +| `null` | `"field": null` | `None` | `NULL` | +| `""` (empty string) | `"field": ""` | `""` | `""` | +| `"http://..."` | `"field": "http://..."` | `"http://..."` | `"http://..."` | + +### Key observations + +1. **`undefined` and missing keys are equivalent** in JSON — `JSON.stringify({ a: undefined })` produces `{}`. DRF treats missing keys as "not provided" and uses the field's default or marks it as missing (if `required=True`). + +2. **Empty string `""` and `null` are different** — DRF distinguishes them. An empty string is a valid value for CharField, while `null` is only accepted when the field has `allow_null=True`. + +3. **React Hook Form returns `""` for cleared text inputs**, not `null` or `undefined`. If the intent is "no value", the form must explicitly normalize `""` → `null` before submission. + +## Convention in this project + +For optional string fields where "no value" is a meaningful state (e.g., `endpoint_url` on ProcessingService), we use `NULL` in the database, not empty string: + +- **Frontend**: Normalize empty strings to `null` in the `onSubmit` handler: `endpoint_url: values.endpoint_url || null` +- **Serializer**: Declare with `allow_null=True, allow_blank=False` to reject `""` at the API boundary +- **Model**: Keep `null=True, blank=True` (blank needed for Django admin), add a `save()` guard to normalize `""` → `None` +- **QuerySet filters**: Use `endpoint_url__isnull=True` instead of `Q(isnull=True) | Q(exact="")` + +### Example: ProcessingService.endpoint_url + +```python +# serializers.py — reject empty string, accept null +endpoint_url = serializers.CharField( + required=False, allow_null=True, allow_blank=False, max_length=1024 +) + +# models.py — safety net for admin/shell usage +def save(self, *args, **kwargs): + if self.endpoint_url == "": + self.endpoint_url = None + super().save(*args, **kwargs) +``` + +```tsx +// form submit — normalize empty input to null +onSubmit={handleSubmit((values) => + onSubmit({ + name: values.name, + customFields: { + endpoint_url: values.endpoint_url || null, + }, + }) +)} +``` + +## DRF serializer field flags reference + +| Flag | Effect | +|---|---| +| `required=True` (default) | Field must be present in input | +| `required=False` | Field can be omitted; uses default | +| `allow_null=True` | Accepts JSON `null` → Python `None` | +| `allow_blank=True` | Accepts `""` for string fields | +| `allow_blank=False` (default) | Rejects `""` with validation error | + +For `CharField` auto-generated from a model field: +- `null=True` on model → `allow_null=True` on serializer +- `blank=True` on model → `allow_blank=True`, `required=False` on serializer + +Explicitly declaring the field on the serializer overrides these auto-generated defaults. diff --git a/ui/src/pages/project/entities/details-form/processing-service-details-form.tsx b/ui/src/pages/project/entities/details-form/processing-service-details-form.tsx index a4322b2fd..716e13afc 100644 --- a/ui/src/pages/project/entities/details-form/processing-service-details-form.tsx +++ b/ui/src/pages/project/entities/details-form/processing-service-details-form.tsx @@ -15,7 +15,7 @@ import { useFormError } from 'utils/useFormError' import { DetailsFormProps, FormValues } from './types' type ProcessingServiceFormValues = FormValues & { - endpoint_url: string + endpoint_url?: string } const config: FormConfig = { @@ -66,7 +66,7 @@ export const ProcessingServiceDetailsForm = ({ name: values.name, description: values.description, customFields: { - endpoint_url: values.endpoint_url, + endpoint_url: values.endpoint_url || null, }, }) )} diff --git a/ui/src/pages/project/entities/details-form/types.ts b/ui/src/pages/project/entities/details-form/types.ts index d048d6ca1..0b858db24 100644 --- a/ui/src/pages/project/entities/details-form/types.ts +++ b/ui/src/pages/project/entities/details-form/types.ts @@ -7,7 +7,9 @@ export type DetailsFormProps = { isSuccess?: boolean onSubmit: ( data: FormValues & { - customFields?: { [key: string]: string | number | object | undefined } + customFields?: { + [key: string]: string | number | object | null | undefined + } } ) => void } From c81b429af0c209dad6547488c1ee28b5595fa983 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 1 Apr 2026 12:30:34 -0700 Subject: [PATCH 4/5] fix: replace save() guard with data migration for empty endpoint_url Co-Authored-By: Claude --- ...0028_normalize_empty_endpoint_url_to_null.py | 17 +++++++++++++++++ ami/ml/models/processing_service.py | 5 ----- 2 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 ami/ml/migrations/0028_normalize_empty_endpoint_url_to_null.py diff --git a/ami/ml/migrations/0028_normalize_empty_endpoint_url_to_null.py b/ami/ml/migrations/0028_normalize_empty_endpoint_url_to_null.py new file mode 100644 index 000000000..510cc8104 --- /dev/null +++ b/ami/ml/migrations/0028_normalize_empty_endpoint_url_to_null.py @@ -0,0 +1,17 @@ +from django.db import migrations + + +def normalize_empty_endpoint_url(apps, schema_editor): + ProcessingService = apps.get_model("ml", "ProcessingService") + ProcessingService.objects.filter(endpoint_url="").update(endpoint_url=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ("ml", "0027_rename_last_checked_to_last_seen"), + ] + + operations = [ + migrations.RunPython(normalize_empty_endpoint_url, migrations.RunPython.noop), + ] diff --git a/ami/ml/models/processing_service.py b/ami/ml/models/processing_service.py index 7ca49472c..fce1aefc5 100644 --- a/ami/ml/models/processing_service.py +++ b/ami/ml/models/processing_service.py @@ -82,11 +82,6 @@ def is_async(self) -> bool: """ return not self.endpoint_url - def save(self, *args, **kwargs): - if self.endpoint_url == "": - self.endpoint_url = None - super().save(*args, **kwargs) - def __str__(self): endpoint_display = self.endpoint_url or "async" return f'#{self.pk} "{self.name}" ({endpoint_display})' From 14e92c73fa7d435bcf13f7b25631d82f99d18ee3 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 1 Apr 2026 12:45:58 -0700 Subject: [PATCH 5/5] fix(ui): add null to EntityFieldValues customFields type Co-Authored-By: Claude --- ui/src/data-services/hooks/entities/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/data-services/hooks/entities/types.ts b/ui/src/data-services/hooks/entities/types.ts index 43d7d4612..0ca392d54 100644 --- a/ui/src/data-services/hooks/entities/types.ts +++ b/ui/src/data-services/hooks/entities/types.ts @@ -2,5 +2,5 @@ export interface EntityFieldValues { description?: string name: string projectId: string - customFields?: { [key: string]: string | number | object | undefined } + customFields?: { [key: string]: string | number | object | null | undefined } }