Skip to content

Commit

Permalink
Present latest job status in collections table and update populate bu…
Browse files Browse the repository at this point in the history
…tton logic (#657)

* feat: add job count to collection table

* feat: add status column to collection table

* feat: update test deployment data to test related jobs API data

* feat: add jobs data to deployment list API endpoint

* feat: add job type to nested job status

* docs: add notes about custom job verbs & vocabulary

* fix: handle edge case in error state component

* feat: add some error handling to populate action

* style: move populate button next to other action buttons

* feat: show job type label on status hover

---------

Co-authored-by: Michael Bunsen <[email protected]>
  • Loading branch information
annavik and mihow authored Jan 17, 2025
1 parent cc42a26 commit b08ff18
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 55 deletions.
5 changes: 5 additions & 0 deletions ami/jobs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,11 @@ class JobType:
name: str
key: str

# @TODO Consider adding custom vocabulary for job types to be used in the UI
# verb: str = "Sync"
# present_participle: str = "syncing"
# past_participle: str = "synced"

@classmethod
def run(cls, job: "Job"):
"""
Expand Down
41 changes: 29 additions & 12 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,40 @@ class Meta:
]


class JobTypeSerializer(serializers.Serializer):
"""
Serializer for the JobType json field in the Job model.
This is duplicated from ami.jobs.serializers to avoid circular imports.
but it is extremely simple.
"""

name = serializers.CharField(read_only=True)
key = serializers.SlugField(read_only=True)


class JobStatusSerializer(DefaultSerializer):
job_type = JobTypeSerializer(read_only=True)

class Meta:
model = Job
fields = [
"id",
"details",
"status",
"job_type",
"created_at",
"updated_at",
]


class DeploymentListSerializer(DefaultSerializer):
events = serializers.SerializerMethodField()
occurrences = serializers.SerializerMethodField()
project = ProjectNestedSerializer(read_only=True)
device = DeviceNestedSerializer(read_only=True)
research_site = SiteNestedSerializer(read_only=True)
jobs = JobStatusSerializer(many=True, read_only=True)

class Meta:
model = Deployment
Expand All @@ -156,6 +184,7 @@ class Meta:
"last_date",
"device",
"research_site",
"jobs",
]

def get_events(self, obj):
Expand Down Expand Up @@ -849,18 +878,6 @@ class Meta:
]


class JobStatusSerializer(DefaultSerializer):
class Meta:
model = Job
fields = [
"id",
"details",
"status",
"created_at",
"updated_at",
]


class SourceImageCollectionNestedSerializer(DefaultSerializer):
class Meta:
model = SourceImageCollection
Expand Down
5 changes: 5 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ class Project(BaseModel):

devices: models.QuerySet["Device"]
sites: models.QuerySet["Site"]
jobs: models.QuerySet["Job"]

def deployments_count(self) -> int:
return self.deployments.count()
Expand Down Expand Up @@ -348,6 +349,7 @@ class Deployment(BaseModel):
events: models.QuerySet["Event"]
captures: models.QuerySet["SourceImage"]
occurrences: models.QuerySet["Occurrence"]
jobs: models.QuerySet["Job"]

objects = DeploymentManager()

Expand Down Expand Up @@ -1199,6 +1201,7 @@ class SourceImage(BaseModel):

detections: models.QuerySet["Detection"]
collections: models.QuerySet["SourceImageCollection"]
jobs: models.QuerySet["Job"]

objects = SourceImageManager()

Expand Down Expand Up @@ -2745,6 +2748,8 @@ class SourceImageCollection(BaseModel):

objects = SourceImageCollectionManager()

jobs: models.QuerySet["Job"]

def source_images_count(self) -> int | None:
# This should always be pre-populated using queryset annotations
# return self.images.count()
Expand Down
45 changes: 34 additions & 11 deletions ami/tests/fixtures/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import uuid

from django.db import transaction
from django.utils import timezone

from ami.main.models import (
Deployment,
Expand Down Expand Up @@ -76,22 +77,44 @@ def create_ml_pipeline(project):
return pipeline


def create_deployment(
project: Project,
data_source,
name="Test Deployment",
) -> Deployment:
"""
Create a test deployment with a data source for source images.
"""
deployment, _ = Deployment.objects.get_or_create(
project=project,
name=name,
defaults=dict(
description=f"Created at {timezone.now()}",
data_source=data_source,
data_source_subdir="/",
data_source_regex=".*\\.jpg",
latitude=45.0,
longitude=-123.0,
research_site=project.sites.first(),
device=project.devices.first(),
),
)
return deployment


def setup_test_project(reuse=True) -> tuple[Project, Deployment]:
if reuse:
project, _ = Project.objects.get_or_create(name="Test Project")
data_source = create_storage_source(project, "Test Data Source")
deployment, _ = Deployment.objects.get_or_create(
project=project, name="Test Deployment", defaults=dict(data_source=data_source)
)
create_ml_pipeline(project)
else:
project = Project.objects.filter(name__startswith="Test Project").first()

if not project or not reuse:
short_id = uuid.uuid4().hex[:8]
project = Project.objects.create(name=f"Test Project {short_id}")
data_source = create_storage_source(project, f"Test Data Source {short_id}")
deployment = Deployment.objects.create(
project=project, name=f"Test Deployment {short_id}", data_source=data_source
)
deployment = create_deployment(project, data_source, f"Test Deployment {short_id}")
create_ml_pipeline(project)
else:
deployment = Deployment.objects.filter(project=project).first()
assert deployment, "No deployment found for existing project. Create a new project instead."

return project, deployment


Expand Down
3 changes: 2 additions & 1 deletion ui/src/components/error-state/error-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const ErrorState = ({ error }: ErrorStateProps) => {
const data = error?.response?.data

const description = useMemo(() => {
const entries = data ? Object.entries(data) : undefined
const entries =
data && typeof data === 'object' ? Object.entries(data) : undefined

if (entries?.length) {
const [key, value] = entries[0]
Expand Down
27 changes: 26 additions & 1 deletion ui/src/data-services/models/collection.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,37 @@
export type ServerCollection = any // TODO: Update this type
import { snakeCaseToSentenceCase } from 'utils/snakeCaseToSentenceCase'
import { Entity } from './entity'
import { Job } from './job'

export type ServerCollection = any // TODO: Update this type

export class Collection extends Entity {
private readonly _jobs: Job[] = []

public constructor(entity: ServerCollection) {
super(entity)

if (this._data.jobs) {
this._jobs = this._data.jobs.map((job: any) => new Job(job))
}
}

get canPopulate(): boolean {
return this.canUpdate && this._data.method !== 'starred'
}

get currentJob(): Job | undefined {
if (!this._jobs.length) {
return
}

return this._jobs.sort((j1: Job, j2: Job) => {
const date1 = new Date(j1.updatedAt as string)
const date2 = new Date(j2.updatedAt as string)

return date2.getTime() - date1.getTime()
})[0]
}

get method(): string {
return this._data.method
}
Expand Down Expand Up @@ -51,6 +72,10 @@ export class Collection extends Entity {
)}%)`
}

get numJobs(): number | undefined {
return this._data.jobs?.length
}

get numOccurrences(): number {
return this._data.occurrences_count
}
Expand Down
34 changes: 17 additions & 17 deletions ui/src/pages/overview/collections/collection-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
import { usePopulateCollection } from 'data-services/hooks/collections/usePopulateCollection'
import { Collection } from 'data-services/models/collection'
import { Button, ButtonTheme } from 'design-system/components/button/button'
import { useState } from 'react'
import { IconType } from 'design-system/components/icon/icon'
import { Tooltip } from 'design-system/components/tooltip/tooltip'
import { STRING, translate } from 'utils/language'

export const PopulateCollection = ({
collection,
}: {
collection: Collection
}) => {
const [timestamp, setTimestamp] = useState<string>()
const { populateCollection, isLoading } = usePopulateCollection()

// When the collection is updated, we consider the population to be completed.
// TODO: It would be better to inspect task status here, but we currently don't have this information.
const isPopulating = isLoading || timestamp === collection.updatedAtDetailed
const { populateCollection, isLoading, error } = usePopulateCollection()

return (
<Button
label={translate(STRING.POPULATE)}
loading={isPopulating}
disabled={isPopulating}
theme={ButtonTheme.Success}
onClick={() => {
populateCollection(collection.id)
setTimestamp(collection.updatedAtDetailed)
}}
/>
<Tooltip
content={
error ? 'Could not populate the collection, please retry.' : undefined
}
>
<Button
disabled={isLoading}
label={translate(STRING.POPULATE)}
icon={error ? IconType.Error : undefined}
loading={isLoading}
onClick={() => populateCollection(collection.id)}
theme={error ? ButtonTheme.Error : ButtonTheme.Success}
/>
</Tooltip>
)
}
53 changes: 40 additions & 13 deletions ui/src/pages/overview/collections/collection-columns.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { API_ROUTES } from 'data-services/constants'
import { Collection } from 'data-services/models/collection'
import { BasicTableCell } from 'design-system/components/table/basic-table-cell/basic-table-cell'
import { StatusTableCell } from 'design-system/components/table/status-table-cell/status-table-cell'
import {
CellTheme,
TableColumn,
TextAlign,
} from 'design-system/components/table/types'
import { Tooltip } from 'design-system/components/tooltip/tooltip'
import { DeleteEntityDialog } from 'pages/overview/entities/delete-entity-dialog'
import { UpdateEntityDialog } from 'pages/overview/entities/entity-details-dialog'
import styles from 'pages/overview/entities/styles.module.scss'
Expand Down Expand Up @@ -53,6 +55,43 @@ export const columns: (projectId: string) => TableColumn<Collection>[] = (
<BasicTableCell value={item.numImagesWithDetectionsLabel} />
),
},
{
id: 'status',
name: 'Status',
renderCell: (item: Collection) => {
if (!item.currentJob) {
return <></>
}

return (
<Tooltip content={item.currentJob.type.label}>
<div>
<StatusTableCell
color={item.currentJob.status.color}
label={item.currentJob.status.label}
/>
</div>
</Tooltip>
)
},
},
{
id: 'jobs',
name: translate(STRING.FIELD_LABEL_JOBS),
styles: {
textAlign: TextAlign.Right,
},
renderCell: (item: Collection) => (
<Link
to={getAppRoute({
to: APP_ROUTES.JOBS({ projectId }),
filters: { source_image_collection: item.id },
})}
>
<BasicTableCell value={item.numJobs} theme={CellTheme.Bubble} />
</Link>
),
},
{
id: 'occurrences',
name: translate(STRING.FIELD_LABEL_OCCURRENCES),
Expand Down Expand Up @@ -86,19 +125,6 @@ export const columns: (projectId: string) => TableColumn<Collection>[] = (
sortField: 'updated_at',
renderCell: (item: Collection) => <BasicTableCell value={item.updatedAt} />,
},
{
id: 'collection-actions',
name: '',
styles: {
padding: '16px',
width: '100%',
},
renderCell: (item: Collection) => (
<div className={styles.entityActions}>
{item.canPopulate && <PopulateCollection collection={item} />}
</div>
),
},
{
id: 'actions',
name: '',
Expand All @@ -108,6 +134,7 @@ export const columns: (projectId: string) => TableColumn<Collection>[] = (
},
renderCell: (item: Collection) => (
<div className={styles.entityActions}>
{item.canPopulate && <PopulateCollection collection={item} />}
{item.canUpdate && editableSamplingMethods.includes(item.method) && (
<UpdateEntityDialog
collection={API_ROUTES.COLLECTIONS}
Expand Down
1 change: 1 addition & 0 deletions ui/src/utils/getAppRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type FilterType =
| 'taxon'
| 'timestamp'
| 'collection'
| 'source_image_collection'
| 'source_image_single'

export const getAppRoute = ({
Expand Down

0 comments on commit b08ff18

Please sign in to comment.