diff --git a/ami/jobs/models.py b/ami/jobs/models.py index d0e3b0c02..bced66575 100644 --- a/ami/jobs/models.py +++ b/ami/jobs/models.py @@ -713,6 +713,26 @@ class DataStorageSyncJob(JobType): regroup_stage_key = "regroup_sessions" regroup_stage_name = "Regroup sessions" + @classmethod + def enqueue_for(cls, deployment: Deployment) -> "Job": + """ + Create and enqueue a sync job for one station, returning the queued job. + + The single place that builds a ``DataStorageSyncJob``, shared by the API's + per-row and bulk sync actions and the Django admin bulk action so all three + create it identically (one job per deployment). Callers own their own + preconditions — that a data source is configured, that skipped stations are + reported — this only builds and enqueues. + """ + job = Job.objects.create( + name=f"Sync captures for deployment {deployment.pk}", + deployment=deployment, + project=deployment.project, + job_type_key=cls.key, + ) + job.enqueue() + return job + @classmethod def run(cls, job: "Job"): """ diff --git a/ami/main/admin.py b/ami/main/admin.py index 404325a93..6101abc96 100644 --- a/ami/main/admin.py +++ b/ami/main/admin.py @@ -218,14 +218,7 @@ def sync_captures(self, request: HttpRequest, queryset: QuerySet[Deployment]) -> if not deployment.data_source_id: skipped.append(f"{deployment} (no data source)") continue - job = Job.objects.create( - name=f"Sync captures for deployment {deployment.pk}", - deployment=deployment, - project=deployment.project, - job_type_key=DataStorageSyncJob.key, - ) - job.enqueue() - queued_job_ids.append(job.pk) + queued_job_ids.append(DataStorageSyncJob.enqueue_for(deployment).pk) msg = f"Queued DataStorageSyncJob for {len(queued_job_ids)} deployments: {queued_job_ids}" if skipped: msg += f" — skipped: {', '.join(skipped)}" diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 2855633e3..308947ae5 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -184,6 +184,7 @@ class DeploymentListSerializer(DefaultSerializer): device = DeviceNestedSerializer(read_only=True) research_site = SiteNestedSerializer(read_only=True) jobs = JobStatusSerializer(many=True, read_only=True) + data_source_connected = serializers.SerializerMethodField() class Meta: model = Deployment @@ -208,8 +209,19 @@ class Meta: "device", "research_site", "jobs", + "data_source_connected", ] + def get_data_source_connected(self, obj: Deployment) -> bool: + """ + Whether the station has a storage source configured. + + The stations list uses this to show the per-row Sync button only where a + sync can succeed, and to count how many stations "Sync all" would cover. + Reads the foreign key id already on the row, so it adds no query. + """ + return obj.data_source_id is not None + def get_events(self, obj): """ Return URL to the events endpoint filtered by this deployment. diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 6ad39a2e5..327cd98f4 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -341,25 +341,48 @@ def sync(self, _request, pk=None) -> Response: """ Queue a task to sync data from the deployment's data source. """ + from ami.jobs.models import DataStorageSyncJob + deployment: Deployment = self.get_object() if deployment and deployment.data_source: - # queued_task = tasks.sync_source_images.delay(deployment.pk) - from ami.jobs.models import DataStorageSyncJob, Job - - job = Job.objects.create( - name=f"Sync captures for deployment {deployment.pk}", - deployment=deployment, - project=deployment.project, - job_type_key=DataStorageSyncJob.key, + job = DataStorageSyncJob.enqueue_for(deployment) + logger.info( + f"Syncing captures for deployment {deployment.pk} from {deployment.data_source_uri} in background." ) - job.enqueue() - msg = f"Syncing captures for deployment {deployment.pk} from {deployment.data_source_uri} in background." - logger.info(msg) - assert deployment.project - return Response({"job_id": job.pk, "project_id": deployment.project.pk}) + return Response({"job_id": job.pk, "project_id": deployment.project_id}) else: raise api_exceptions.ValidationError(detail="Deployment must have a data source to sync captures from") + @action(detail=False, methods=["post"], name="sync-all", url_path="sync-all") + def sync_all(self, request) -> Response: + """ + Queue a sync job for every station in the project that has a storage source. + + Enqueues one ``DataStorageSyncJob`` per connected station (separate jobs, + not one consolidated job), matching the per-row ``sync`` action and the + admin bulk action. Requires the ``project_id`` query parameter and the + sync permission on the project. + """ + project = self.get_active_project() + if not project: + raise api_exceptions.ValidationError(detail="A project_id is required to sync all stations.") + + # ObjectPermission.has_permission() is a no-op and has_object_permission() + # only runs for detail actions (via get_object()), so a detail=False action + # must check permissions itself. Probe the same sync permission the per-row + # action enforces, resolved against the project. + if not Deployment(project=project).check_permission(request.user, "sync"): + raise api_exceptions.PermissionDenied( + detail="You do not have permission to sync stations in this project." + ) + + from ami.jobs.models import DataStorageSyncJob + + deployments = self.get_queryset().filter(data_source__isnull=False) + job_ids = [DataStorageSyncJob.enqueue_for(deployment).pk for deployment in deployments] + logger.info(f"Queued {len(job_ids)} DataStorageSyncJob(s) for project {project.pk}: {job_ids}") + return Response({"job_ids": job_ids, "queued": len(job_ids), "project_id": project.pk}) + @action(detail=True, methods=["post"], name="regroup-sessions", url_path="regroup-sessions") def regroup_sessions(self, _request, pk=None) -> Response: """ diff --git a/ami/main/migrations/0095_grant_sync_deployment_to_mldatamanager.py b/ami/main/migrations/0095_grant_sync_deployment_to_mldatamanager.py new file mode 100644 index 000000000..caad9d968 --- /dev/null +++ b/ami/main/migrations/0095_grant_sync_deployment_to_mldatamanager.py @@ -0,0 +1,114 @@ +""" +Grant the existing ``sync_deployment`` permission to ``MLDataManager`` role +groups on projects that already exist. + +``MLDataManager`` already holds ``run_data_storage_sync_job`` (it can run/retry a +sync job) but not ``sync_deployment`` (which gates *starting* a sync from a +station via ``POST /api/v2/deployments//sync/`` and the bulk +``.../sync-all/`` action). This closes that gap so ML data managers can trigger +syncs, not only manage the resulting jobs. ``ProjectManager`` already has the +permission and is unaffected. + +Permissions here are guardian **object-level** grants on each project, which is +what ``get_perms(user, project)`` and ``user.has_perm("sync_deployment", +project)`` read. Adding the permission only to ``group.permissions`` (a global +Django permission) is not enough — it would not appear in ``get_perms`` and the +Sync buttons / endpoint would stay inaccessible. So this migration mirrors +``create_roles_for_project``: it adds the global permission for parity and, more +importantly, creates the object-level ``GroupObjectPermission`` row per project. + +New projects pick this up automatically through ``create_roles_for_project`` +(``MLDataManager`` now includes the permission). A ``post_migrate`` signal +(``ami.main.apps`` → ``create_roles``) also re-syncs every project's role +permissions on migrate, so this backfill is belt-and-suspenders; it is kept so +the grant is explicit and self-contained rather than relying on that signal. + +The ``sync_deployment`` permission itself is already defined on +``Project.Meta.permissions``, so there is no model/schema change here — only a +data backfill. +""" + +from django.db import migrations +from django.db.models import Q + + +def _sync_permission(apps): + Permission = apps.get_model("auth", "Permission") + ContentType = apps.get_model("contenttypes", "ContentType") + try: + project_ct = ContentType.objects.get(app_label="main", model="project") + except ContentType.DoesNotExist: + return None, None + try: + return Permission.objects.get(codename="sync_deployment", content_type=project_ct), project_ct + except Permission.DoesNotExist: + return None, None + + +def _project_pk_from_group(group): + # Group names are "{project_pk}_{project_name}_{RoleName}"; the pk is the + # immutable leading segment (the name can contain underscores). + try: + return int(group.name.split("_", 1)[0]) + except (ValueError, IndexError): + return None + + +def grant_sync_to_mldatamanager(apps, schema_editor): + Group = apps.get_model("auth", "Group") + GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission") + + perm, project_ct = _sync_permission(apps) + if perm is None: + return + + for group in Group.objects.filter(Q(name__endswith="_MLDataManager")): + project_pk = _project_pk_from_group(group) + if project_pk is None: + continue + # Global add for parity with create_roles_for_project; the object-level + # row below is what get_perms()/has_perm(perm, project) actually read. + group.permissions.add(perm) + GroupObjectPermission.objects.get_or_create( + permission=perm, + content_type=project_ct, + object_pk=str(project_pk), + group=group, + ) + + +def revoke_sync_from_mldatamanager(apps, schema_editor): + Group = apps.get_model("auth", "Group") + GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission") + + perm, project_ct = _sync_permission(apps) + if perm is None: + return + + # Only touch MLDataManager groups; ProjectManager holds sync_deployment + # independently and must keep it. + for group in Group.objects.filter(Q(name__endswith="_MLDataManager")): + project_pk = _project_pk_from_group(group) + if project_pk is None: + continue + group.permissions.remove(perm) + GroupObjectPermission.objects.filter( + permission=perm, + content_type=project_ct, + object_pk=str(project_pk), + group=group, + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0094_enable_async_pipeline_workers"), + ("guardian", "0002_generic_permissions_index"), + ] + + operations = [ + migrations.RunPython( + grant_sync_to_mldatamanager, + revoke_sync_from_mldatamanager, + ), + ] diff --git a/ami/main/tests.py b/ami/main/tests.py index c9e3b0d8f..a0d0253a1 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -51,7 +51,14 @@ ) from ami.tests.fixtures.storage import populate_bucket from ami.users.models import User -from ami.users.roles import BasicMember, Identifier, MLDataManager, ProjectManager, create_roles_for_project +from ami.users.roles import ( + BasicMember, + Identifier, + MLDataManager, + ProjectManager, + Researcher, + create_roles_for_project, +) logger = logging.getLogger(__name__) @@ -2900,6 +2907,179 @@ def test_sync_creates_events_and_updates_counts(self): logger.info(f"Initial events count: {initial_events_count}, Updated events count: {updated_events.count()}") +class TestDeploymentSyncAll(APITestCase): + """ + The bulk "Sync all" endpoint enqueues one sync job per connected station and + is gated by the same sync permission the per-row action enforces. + + Pins three guarantees: the ``data_source_connected`` flag the frontend reads, + the permission matrix (a ``detail=False`` action must check permissions + itself), and that only stations with a storage source get a job. + """ + + def setUp(self): + super().setUp() + from unittest import mock + + self.project = Project.objects.create(name="Sync All Project", description="Sync-all tests") + create_roles_for_project(self.project) + + self.superuser = User.objects.create_superuser(email="super-syncall@insectai.org", password="password123") + self.pm_user = User.objects.create_user(email="pm-syncall@insectai.org", password="password123") + self.ml_user = User.objects.create_user(email="ml-syncall@insectai.org", password="password123") + self.researcher = User.objects.create_user(email="researcher-syncall@insectai.org", password="password123") + self.identifier = User.objects.create_user(email="identifier-syncall@insectai.org", password="password123") + self.basic_user = User.objects.create_user(email="basic-syncall@insectai.org", password="password123") + self.outsider = User.objects.create_user(email="outsider-syncall@insectai.org", password="password123") + ProjectManager.assign_user(self.pm_user, self.project) + MLDataManager.assign_user(self.ml_user, self.project) + Researcher.assign_user(self.researcher, self.project) + Identifier.assign_user(self.identifier, self.project) + BasicMember.assign_user(self.basic_user, self.project) + + source = S3StorageSource.objects.create( + name="Sync All Source", + bucket="test-bucket", + access_key="fake-access-key", + secret_key="fake-secret-key", + project=self.project, + ) + self.connected = Deployment.objects.create(name="Connected", project=self.project, data_source=source) + self.unconnected = Deployment.objects.create(name="Unconnected", project=self.project) + + # Keep the endpoint tests off the broker: assert the job rows and enqueue + # calls, not real Celery dispatch. + patcher = mock.patch("ami.jobs.models.Job.enqueue") + self.mock_enqueue = patcher.start() + self.addCleanup(patcher.stop) + + self.url = "/api/v2/deployments/sync-all/" + + def test_data_source_connected_field_in_list(self): + self.client.force_authenticate(self.superuser) + response = self.client.get(f"/api/v2/deployments/?project_id={self.project.pk}") + self.assertEqual(response.status_code, 200) + flags = {row["id"]: row["data_source_connected"] for row in response.data["results"]} + self.assertTrue(flags[self.connected.pk], "Connected station should report data_source_connected=True") + self.assertFalse(flags[self.unconnected.pk], "Unconnected station should report data_source_connected=False") + + def test_requires_project_id(self): + self.client.force_authenticate(self.superuser) + response = self.client.post(self.url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_permission_matrix(self): + # Sync is allowed for MLDataManager and ProjectManager (and superusers), + # not for Researcher, Identifier, BasicMember, or non-members. + matrix = [ + ("superuser", self.superuser, status.HTTP_200_OK), + ("ProjectManager", self.pm_user, status.HTTP_200_OK), + ("MLDataManager", self.ml_user, status.HTTP_200_OK), + ("Researcher", self.researcher, status.HTTP_403_FORBIDDEN), + ("Identifier", self.identifier, status.HTTP_403_FORBIDDEN), + ("BasicMember", self.basic_user, status.HTTP_403_FORBIDDEN), + ("outsider", self.outsider, status.HTTP_403_FORBIDDEN), + ] + for role_name, user, expected in matrix: + with self.subTest(role=role_name): + self.client.force_authenticate(user) + response = self.client.post(f"{self.url}?project_id={self.project.pk}") + self.assertEqual(response.status_code, expected, f"{role_name} got {response.status_code}") + + def test_anonymous_denied(self): + self.client.force_authenticate(None) + response = self.client.post(f"{self.url}?project_id={self.project.pk}") + self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)) + + def test_enqueues_one_job_per_connected_station(self): + from ami.jobs.models import DataStorageSyncJob + + self.client.force_authenticate(self.pm_user) + response = self.client.post(f"{self.url}?project_id={self.project.pk}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["queued"], 1) + self.assertEqual(len(response.data["job_ids"]), 1) + self.assertEqual(self.mock_enqueue.call_count, 1, "One job should be enqueued") + + jobs = Job.objects.filter(project=self.project, job_type_key=DataStorageSyncJob.key) + self.assertEqual(jobs.count(), 1, "Exactly one sync job, for the connected station") + self.assertEqual(jobs.first().deployment_id, self.connected.pk, "Unconnected station must be skipped") + + def test_per_row_sync_permission_matrix(self): + # The per-row POST /deployments/{id}/sync/ action enforces the same + # sync_deployment permission as the bulk endpoint. This pins that the + # newly-granted MLDataManager role can start a sync and that roles + # without the permission are refused (403), so the per-row and bulk + # paths cannot silently diverge on who may sync. + url = f"/api/v2/deployments/{self.connected.pk}/sync/" + matrix = [ + ("superuser", self.superuser, status.HTTP_200_OK), + ("ProjectManager", self.pm_user, status.HTTP_200_OK), + ("MLDataManager", self.ml_user, status.HTTP_200_OK), + ("Researcher", self.researcher, status.HTTP_403_FORBIDDEN), + ("Identifier", self.identifier, status.HTTP_403_FORBIDDEN), + ("BasicMember", self.basic_user, status.HTTP_403_FORBIDDEN), + ("outsider", self.outsider, status.HTTP_403_FORBIDDEN), + ] + for role_name, user, expected in matrix: + with self.subTest(role=role_name): + self.client.force_authenticate(user) + response = self.client.post(url) + self.assertEqual(response.status_code, expected, f"{role_name} got {response.status_code}") + + +class TestSyncDeploymentBackfillMigration(APITestCase): + """The 0095 backfill grants OBJECT-LEVEL sync_deployment to existing projects' + MLDataManager groups, not just a global group permission. + + A global-only grant (``group.permissions.add``) would leave ``get_perms`` and + ``has_perm(perm, project)`` — the checks the endpoint and UI actually use — + returning False, so the backfill would silently no-op for existing projects. + This pins the object-level path. + """ + + def test_backfill_grants_object_level_sync_to_mldatamanager(self): + import importlib + from unittest import mock + + from django.apps import apps as global_apps + from django.contrib.auth.models import Group + + project = Project.objects.create(name="Sync Backfill Project") + create_roles_for_project(project) + ml_user = User.objects.create_user(email="ml-backfill@insectai.org", password="password123") + MLDataManager.assign_user(ml_user, project) + source = S3StorageSource.objects.create( + name="Backfill Source", + bucket="test-bucket", + access_key="fake-access-key", + secret_key="fake-secret-key", + project=project, + ) + Deployment.objects.create(name="Backfill Station", project=project, data_source=source) + + mldm_group = Group.objects.get(name=f"{project.pk}_{project.name}_MLDataManager") + + # Simulate a project created before MLDataManager gained the permission: + # strip the object-level grant so the sync endpoint is denied. + remove_perm("sync_deployment", mldm_group, project) + self.assertNotIn("sync_deployment", get_perms(mldm_group, project)) + + self.client.force_authenticate(ml_user) + denied = self.client.post(f"/api/v2/deployments/sync-all/?project_id={project.pk}") + self.assertEqual(denied.status_code, status.HTTP_403_FORBIDDEN) + + # Run the backfill and confirm it restores the object-level permission + # (a global-only grant would leave get_perms unchanged and the POST 403). + migration = importlib.import_module("ami.main.migrations.0095_grant_sync_deployment_to_mldatamanager") + migration.grant_sync_to_mldatamanager(global_apps, None) + + self.assertIn("sync_deployment", get_perms(mldm_group, project)) + with mock.patch("ami.jobs.models.Job.enqueue"): + granted = self.client.post(f"/api/v2/deployments/sync-all/?project_id={project.pk}") + self.assertEqual(granted.status_code, status.HTTP_200_OK) + + class TestFineGrainedJobRunPermission(APITestCase): def setUp(self): super().setUp() diff --git a/ami/users/roles.py b/ami/users/roles.py index 5a5f9f6c8..718146e41 100644 --- a/ami/users/roles.py +++ b/ami/users/roles.py @@ -141,6 +141,9 @@ class MLDataManager(Role): Project.Permissions.RUN_ML_JOB, Project.Permissions.RUN_POPULATE_CAPTURES_COLLECTION_JOB, Project.Permissions.RUN_DATA_STORAGE_SYNC_JOB, + # Can start a storage sync from a station, not only run/retry the job it + # creates. ProjectManager also has this (inherits MLDataManager). + Project.Permissions.SYNC_DEPLOYMENT, Project.Permissions.RUN_REGROUP_EVENTS_JOB, Project.Permissions.RUN_DATA_EXPORT_JOB, Project.Permissions.DELETE_OCCURRENCES, diff --git a/ui/src/data-services/hooks/deployments/useSyncAllDeployments.ts b/ui/src/data-services/hooks/deployments/useSyncAllDeployments.ts new file mode 100644 index 000000000..268e30288 --- /dev/null +++ b/ui/src/data-services/hooks/deployments/useSyncAllDeployments.ts @@ -0,0 +1,39 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import axios from 'axios' +import { API_ROUTES, API_URL } from 'data-services/constants' +import { getAuthHeader } from 'data-services/utils' +import { useUser } from 'utils/user/userContext' + +export const useSyncAllDeployments = () => { + const { user } = useUser() + const queryClient = useQueryClient() + + const { mutateAsync, reset, isLoading, isSuccess, error, data } = useMutation( + { + mutationFn: (projectId: string) => + axios.post<{ job_ids: number[]; queued: number; project_id: number }>( + `${API_URL}/${API_ROUTES.DEPLOYMENTS}/sync-all/?project_id=${projectId}`, + undefined, + { + headers: getAuthHeader(user), + } + ), + onSuccess: (resp) => { + queryClient.invalidateQueries([API_ROUTES.JOBS]) + queryClient.invalidateQueries([API_ROUTES.CAPTURES]) + queryClient.invalidateQueries([API_ROUTES.DEPLOYMENTS]) + + return resp.data + }, + } + ) + + return { + syncAllDeployments: mutateAsync, + reset, + isLoading, + isSuccess, + error, + data, + } +} diff --git a/ui/src/data-services/hooks/deployments/useSyncDeploymentSourceImages.ts b/ui/src/data-services/hooks/deployments/useSyncDeploymentSourceImages.ts index d804cdc82..4d67250d2 100644 --- a/ui/src/data-services/hooks/deployments/useSyncDeploymentSourceImages.ts +++ b/ui/src/data-services/hooks/deployments/useSyncDeploymentSourceImages.ts @@ -8,25 +8,28 @@ export const useSyncDeploymentSourceImages = () => { const { user } = useUser() const queryClient = useQueryClient() - const { mutateAsync, isLoading, isSuccess, error, data } = useMutation({ - mutationFn: (id: string) => - axios.post<{ job_id: number; project_id: number }>( - `${API_URL}/${API_ROUTES.DEPLOYMENTS}/${id}/sync/`, - undefined, - { - headers: getAuthHeader(user), - } - ), - onSuccess: (resp) => { - queryClient.invalidateQueries([API_ROUTES.JOBS]) - queryClient.invalidateQueries([API_ROUTES.CAPTURES]) + const { mutateAsync, reset, isLoading, isSuccess, error, data } = useMutation( + { + mutationFn: (id: string) => + axios.post<{ job_id: number; project_id: number }>( + `${API_URL}/${API_ROUTES.DEPLOYMENTS}/${id}/sync/`, + undefined, + { + headers: getAuthHeader(user), + } + ), + onSuccess: (resp) => { + queryClient.invalidateQueries([API_ROUTES.JOBS]) + queryClient.invalidateQueries([API_ROUTES.CAPTURES]) - return resp.data - }, - }) + return resp.data + }, + } + ) return { syncDeploymentSourceImages: mutateAsync, + reset, isLoading, isSuccess, error, diff --git a/ui/src/data-services/models/deployment.ts b/ui/src/data-services/models/deployment.ts index 069896bf8..c99c9865e 100644 --- a/ui/src/data-services/models/deployment.ts +++ b/ui/src/data-services/models/deployment.ts @@ -25,10 +25,21 @@ export class Deployment extends Entity { return this._deployment.user_permissions.includes(UserPermission.Delete) } + get canSync(): boolean { + // Granted to ML data managers, project managers, and superusers. Superusers + // receive `sync` here too (guardian returns every project permission for + // them), so this getter alone is the sync gate. + return this._deployment.user_permissions.includes(UserPermission.Sync) + } + get canUpdate(): boolean { return this._deployment.user_permissions.includes(UserPermission.Update) } + get dataSourceConnected(): boolean { + return this._deployment.data_source_connected ?? false + } + get currentJob(): Job | undefined { if (!this._jobs.length) { return diff --git a/ui/src/pages/deployments/deployment-columns.tsx b/ui/src/pages/deployments/deployment-columns.tsx index 95fb1a9f4..0a86d0a36 100644 --- a/ui/src/pages/deployments/deployment-columns.tsx +++ b/ui/src/pages/deployments/deployment-columns.tsx @@ -12,6 +12,7 @@ import { Toolbar, } from 'nova-ui-kit' import { DeleteEntityDialog } from 'pages/project/entities/delete-entity-dialog' +import { SyncDeploymentDialog } from 'pages/deployments/sync-deployment-dialog' import { Link } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' @@ -231,6 +232,9 @@ export const columns = ({ sticky: true, renderCell: (item: Deployment) => ( + {item.canSync && item.dataSourceConnected && ( + + )} {item.canDelete && ( { }) const canCreate = userPermissions?.includes(UserPermission.Create) const tableColumns = columns({ projectId: projectId as string }) + const syncableCount = + deployments?.filter( + (deployment) => deployment.canSync && deployment.dataSourceConnected + ).length ?? 0 return ( <> @@ -46,6 +51,12 @@ export const Deployments = () => { tooltip={translate(STRING.TOOLTIP_DEPLOYMENT)} > {canCreate ? : null} + {syncableCount > 0 ? ( + + ) : null} { + const [isOpen, setIsOpen] = useState(false) + const { syncAllDeployments, reset, isLoading, isSuccess, error, data } = + useSyncAllDeployments() + + const queued = data?.data.queued + const errorMessage = error ? parseServerError(error)?.message : undefined + + return ( + { + setIsOpen(open) + // The hook stays mounted with the header, so reset on open to offer a + // fresh sync instead of the previous run's result. Skip the reset while a + // request is in flight: reset() clears the loading state without + // cancelling the request, so resetting here would re-enable Sync all and + // allow a duplicate bulk run. + if (open && !isLoading) { + reset() + } + }} + > + + + + + {errorMessage && } + +
+ + + {isSuccess && queued !== undefined && ( + + + + + + )} +
+
+
+
+ ) +} diff --git a/ui/src/pages/deployments/sync-deployment-dialog.tsx b/ui/src/pages/deployments/sync-deployment-dialog.tsx new file mode 100644 index 000000000..fb975aa36 --- /dev/null +++ b/ui/src/pages/deployments/sync-deployment-dialog.tsx @@ -0,0 +1,112 @@ +import { FormError, FormSection } from 'components/form/layout/layout' +import { useSyncDeploymentSourceImages } from 'data-services/hooks/deployments/useSyncDeploymentSourceImages' +import { CheckIcon, EyeIcon, Loader2Icon, RefreshCwIcon } from 'lucide-react' +import { BasicTooltip, Button, buttonVariants, Dialog } from 'nova-ui-kit' +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' +import { parseServerError } from 'utils/parseServerError/parseServerError' + +export const SyncDeploymentDialog = ({ + id, + projectId, +}: { + id: string + projectId: string +}) => { + const [isOpen, setIsOpen] = useState(false) + const { + syncDeploymentSourceImages, + reset, + isLoading, + isSuccess, + error, + data, + } = useSyncDeploymentSourceImages() + + const jobId = data?.data.job_id + const errorMessage = error ? parseServerError(error)?.message : undefined + + return ( + { + setIsOpen(open) + // The hook is mounted for the whole row, so success/error/data survive + // a close. Reset on open so reopening offers a fresh sync instead of the + // previous attempt's stale state. Skip the reset while a request is in + // flight: reset() clears the loading state without cancelling the + // request, so resetting here would re-enable Sync and allow a duplicate + // job for the same deployment. + if (open && !isLoading) { + reset() + } + }} + > + + + + + {errorMessage && } + +
+ + + {isSuccess && jobId !== undefined && ( + + + + + + )} +
+
+
+
+ ) +} diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 6c6b3c2be..f2be6618c 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -46,8 +46,13 @@ export enum STRING { SUBMIT, SUGGEST_ID_SHORT, SUGGEST_ID, + SYNC, + SYNC_ALL, + SYNC_CAPTURES, VIEW_ALL, VIEW_DOCS, + VIEW_JOB, + VIEW_JOBS, VIEW_PUBLIC_PROJECTS, ZOOM_IN, ZOOM_OUT, @@ -210,6 +215,8 @@ export enum STRING { MESSAGE_RESET_INSTRUCTIONS_SENT, MESSAGE_RESULT_RANGE, MESSAGE_SIGNED_UP, + MESSAGE_SYNC_ALL_CONFIRM, + MESSAGE_SYNC_CONFIRM, MESSAGE_VALUE_INVALID, MESSAGE_VALUE_MISSING, @@ -406,8 +413,13 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.SUBMIT]: 'Submit', [STRING.SUGGEST_ID_SHORT]: 'Suggest', [STRING.SUGGEST_ID]: 'Suggest ID', + [STRING.SYNC]: 'Sync', + [STRING.SYNC_ALL]: 'Sync all', + [STRING.SYNC_CAPTURES]: 'Sync captures', [STRING.VIEW_ALL]: 'View all', [STRING.VIEW_DOCS]: 'View docs', + [STRING.VIEW_JOB]: 'View job', + [STRING.VIEW_JOBS]: 'View jobs', [STRING.VIEW_PUBLIC_PROJECTS]: 'View public projects', [STRING.ZOOM_IN]: 'Zoom in', [STRING.ZOOM_OUT]: 'Zoom out', @@ -595,6 +607,10 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.MESSAGE_RESULT_RANGE]: 'Showing {{start}}-{{end}} of {{total}} result(s)', [STRING.MESSAGE_SIGNED_UP]: 'Signed up successfully!', + [STRING.MESSAGE_SYNC_ALL_CONFIRM]: + 'This starts a background sync job for each of the {{count}} station(s) with a storage source.', + [STRING.MESSAGE_SYNC_CONFIRM]: + 'This scans the connected storage source and imports any new captures as a background job.', [STRING.MESSAGE_VALUE_INVALID]: 'Please provide a valid value', [STRING.MESSAGE_VALUE_MISSING]: 'Please provide a value', diff --git a/ui/src/utils/parseServerError/parseServerError.test.ts b/ui/src/utils/parseServerError/parseServerError.test.ts index a91c21b9f..8741764d6 100644 --- a/ui/src/utils/parseServerError/parseServerError.test.ts +++ b/ui/src/utils/parseServerError/parseServerError.test.ts @@ -73,6 +73,20 @@ describe('parseServerError', () => { }) }) + test('reads a top-level list of strings (DRF plain-string ValidationError) as the message', () => { + const EXAMPLE_ERROR = { + message: 'Request failed with status code 400', + response: { + data: ['Deployment must have a data source to sync captures from'], + }, + } + + expect(parseServerError(EXAMPLE_ERROR)).toEqual({ + message: 'Deployment must have a data source to sync captures from', + fieldErrors: [], + }) + }) + test('returns a default error if no information is present', () => { const { message, fieldErrors } = parseServerError({}) diff --git a/ui/src/utils/parseServerError/parseServerError.ts b/ui/src/utils/parseServerError/parseServerError.ts index 5c11efe2e..a149038de 100644 --- a/ui/src/utils/parseServerError/parseServerError.ts +++ b/ui/src/utils/parseServerError/parseServerError.ts @@ -2,8 +2,18 @@ export const parseServerError = (error: any) => { let message = '' const fieldErrors: { key: string; message: string }[] = [] - if (error.response?.data && typeof error.response.data === 'object') { - Object.entries(error.response.data).forEach(([key, details]) => { + const data = error.response?.data + + if (Array.isArray(data)) { + // DRF serializes `raise ValidationError("some message")` as a top-level + // JSON list of strings, e.g. ["Deployment must have a data source ..."]. + // Join them into a single message so the reason reaches the user instead + // of the generic axios "Request failed with status code 400". + message = data + .filter((entry) => typeof entry === 'string' && entry.length) + .join(' ') + } else if (data && typeof data === 'object') { + Object.entries(data).forEach(([key, details]) => { if (key && details) { if (key === 'non_field_errors' || key === 'detail') { message = details as string diff --git a/ui/src/utils/user/types.ts b/ui/src/utils/user/types.ts index 1a6d5833e..66cc5a53b 100644 --- a/ui/src/utils/user/types.ts +++ b/ui/src/utils/user/types.ts @@ -17,6 +17,7 @@ export enum UserPermission { Run = 'run', // Custom job permission RunSingleImage = 'run_single_image_ml_job', // Custom job permission Star = 'star', + Sync = 'sync', // Custom deployment permission (sync_deployment) Update = 'update', }