Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.contrib.postgres.search import TrigramSimilarity
from django.core import exceptions
from django.db import models
from django.db.models import Prefetch, Q
from django.db.models import OuterRef, Prefetch, Q, Subquery
from django.db.models.functions import Coalesce
from django.db.models.query import QuerySet
from django.forms import BooleanField, CharField, IntegerField
Expand Down Expand Up @@ -145,6 +145,12 @@ class DefaultReadOnlyViewSet(DefaultViewSetMixin, viewsets.ReadOnlyModelViewSet)
class ProjectPagination(LimitOffsetPaginationWithPermissions):
default_limit = 40

def get_count(self, queryset):
# The recent-activity orderings annotate correlated subqueries onto the
# queryset. They don't change the row count, so strip them (and ordering)
# before counting to keep the pagination COUNT query cheap.
return super().get_count(queryset.order_by().values("pk"))


class ProjectViewSet(DefaultViewSet, ProjectMixin):
"""
Expand All @@ -155,6 +161,17 @@ class ProjectViewSet(DefaultViewSet, ProjectMixin):
serializer_class = ProjectSerializer
pagination_class = ProjectPagination
permission_classes = [ObjectPermission]
ordering_fields = [
"name",
"created_at",
"updated_at",
# The three below are not Project fields; get_queryset annotates them on
# demand (see below). last_capture_timestamp mirrors the DeploymentViewSet
# ordering of the same name, but is a per-project rollup of capture times.
"last_capture_timestamp",
"last_occurrence_updated_at",
"last_job_updated_at",
]

def get_queryset(self):
qs: ProjectQuerySet = super().get_queryset() # type: ignore
Expand All @@ -166,6 +183,38 @@ def get_queryset(self):
raise PermissionDenied("You can only view your projects")
if user:
qs = qs.filter_by_user(user)

# Annotate "recent activity" fields only when sorting by them, so the
# default list stays cheap. Each is a correlated subquery returning one
# row via a covering index, and only one is ever added per request.
ordering = {field.lstrip("-") for field in self.request.query_params.get("ordering", "").split(",") if field}
if "last_capture_timestamp" in ordering:
# Live max capture time per project (Index Only Scan on
# main_source_proj_ts_desc_idx); kept live rather than reading the
# denormalized Deployment field so the sort never lags ingestion.
# timestamp is nullable, and DESC sorts NULLs first, so exclude them
# explicitly — otherwise a single undated capture masks the real max.
qs = qs.annotate(
last_capture_timestamp=Subquery(
SourceImage.objects.filter(project=OuterRef("pk"), timestamp__isnull=False)
.order_by("-timestamp")
.values("timestamp")[:1]
)
)
if "last_occurrence_updated_at" in ordering:
qs = qs.annotate(
last_occurrence_updated_at=Subquery(
Occurrence.objects.filter(project=OuterRef("pk")).order_by("-updated_at").values("updated_at")[:1]
)
)
if "last_job_updated_at" in ordering:
from ami.jobs.models import Job

qs = qs.annotate(
last_job_updated_at=Subquery(
Job.objects.filter(project=OuterRef("pk")).order_by("-updated_at").values("updated_at")[:1]
)
)
return qs

def get_serializer_class(self):
Expand Down
34 changes: 34 additions & 0 deletions ami/main/migrations/0085_project_activity_sort_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models


class Migration(migrations.Migration):
"""Add the composite index that backs the project "recent identifications" sort.

Occurrence is large in production, so the index is built CONCURRENTLY to avoid
taking a write lock during deploy. This requires a non-atomic migration.

Building the index can take longer than a configured ``statement_timeout``
(development sets 30s, see ``config/settings/local.py``; a production role may
set one too). ``CREATE INDEX CONCURRENTLY`` runs as a single statement and is
subject to that timeout, so we clear it for this connection before building.
"""

atomic = False

dependencies = [
("main", "0084_revoke_delete_job_from_roles"),
]

operations = [
# Runtime SET overrides the startup "-c statement_timeout" option and
# persists for the rest of this (non-atomic) migration's connection.
migrations.RunSQL(
sql="SET statement_timeout = 0;",
reverse_sql=migrations.RunSQL.noop,
),
AddIndexConcurrently(
model_name="occurrence",
index=models.Index(fields=["project", "-updated_at"], name="occur_proj_updated_desc_idx"),
),
]
Comment on lines +23 to +34
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset statement_timeout after the concurrent index build.

SET statement_timeout = 0 is session-scoped, and this migration is non-atomic. Without a trailing reset, later migrations executed on the same connection inherit timeout=0, which silently disables that safeguard for the rest of the migrate run.

Suggested fix
     operations = [
         # Runtime SET overrides the startup "-c statement_timeout" option and
         # persists for the rest of this (non-atomic) migration's connection.
         migrations.RunSQL(
             sql="SET statement_timeout = 0;",
             reverse_sql=migrations.RunSQL.noop,
         ),
         AddIndexConcurrently(
             model_name="occurrence",
             index=models.Index(fields=["project", "-updated_at"], name="occur_proj_updated_desc_idx"),
         ),
+        migrations.RunSQL(
+            sql="RESET statement_timeout;",
+            reverse_sql=migrations.RunSQL.noop,
+        ),
     ]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
operations = [
# Runtime SET overrides the startup "-c statement_timeout" option and
# persists for the rest of this (non-atomic) migration's connection.
migrations.RunSQL(
sql="SET statement_timeout = 0;",
reverse_sql=migrations.RunSQL.noop,
),
AddIndexConcurrently(
model_name="occurrence",
index=models.Index(fields=["project", "-updated_at"], name="occur_proj_updated_desc_idx"),
),
]
operations = [
# Runtime SET overrides the startup "-c statement_timeout" option and
# persists for the rest of this (non-atomic) migration's connection.
migrations.RunSQL(
sql="SET statement_timeout = 0;",
reverse_sql=migrations.RunSQL.noop,
),
AddIndexConcurrently(
model_name="occurrence",
index=models.Index(fields=["project", "-updated_at"], name="occur_proj_updated_desc_idx"),
),
migrations.RunSQL(
sql="RESET statement_timeout;",
reverse_sql=migrations.RunSQL.noop,
),
]
🧰 Tools
🪛 Ruff (0.15.13)

[warning] 23-34: Mutable default value for class attribute

(RUF012)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ami/main/migrations/0085_project_activity_sort_indexes.py` around lines 23 -
34, The migration sets a session-wide statement_timeout to 0 but never resets
it, so later non-atomic migrations inherit a disabled timeout; add a trailing
migrations.RunSQL after the AddIndexConcurrently that executes "SET
statement_timeout = DEFAULT;" (use migrations.RunSQL.noop for its reverse_sql)
so the session timeout is restored after the concurrent index build for the
model "occurrence" and index "occur_proj_updated_desc_idx".

36 changes: 36 additions & 0 deletions ami/main/migrations/0086_sourceimage_recent_capture_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models


class Migration(migrations.Migration):
"""Add the index backing the project "recent captures" sort.

SourceImage is large in production (tens of millions of rows), so the index
is built CONCURRENTLY to avoid taking a write lock during deploy, which
requires a non-atomic migration.

A concurrent build on a table this size can exceed a configured
``statement_timeout`` (development sets 30s, see ``config/settings/local.py``;
a production role may set one too). ``CREATE INDEX CONCURRENTLY`` runs as a
single statement subject to that timeout, so clear it for this connection
before building.
"""

atomic = False

dependencies = [
("main", "0085_project_activity_sort_indexes"),
]

operations = [
# Runtime SET overrides the startup "-c statement_timeout" option and
# persists for the rest of this (non-atomic) migration's connection.
migrations.RunSQL(
sql="SET statement_timeout = 0;",
reverse_sql=migrations.RunSQL.noop,
),
AddIndexConcurrently(
model_name="sourceimage",
index=models.Index(fields=["project", "-timestamp"], name="main_source_proj_ts_desc_idx"),
),
]
6 changes: 6 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2219,6 +2219,9 @@ class Meta:
models.Index(fields=["deployment", "timestamp"]),
models.Index(fields=["event", "timestamp"]),
models.Index(fields=["timestamp"]),
# Backs the project "recent captures" sort: a per-project max(timestamp)
# lookup (see ProjectViewSet ordering "last_capture_timestamp").
models.Index(fields=["project", "-timestamp"], name="main_source_proj_ts_desc_idx"),
]


Expand Down Expand Up @@ -3340,6 +3343,9 @@ class Meta:
fields=["determination_id", "project_id", "event_id"],
name="occur_det_proj_evt",
),
# Supports sorting projects by their most recently updated occurrence
# (see ProjectViewSet ordering "last_occurrence_updated_at").
models.Index(fields=["project", "-updated_at"], name="occur_proj_updated_desc_idx"),
]


Expand Down
2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"leaflet": "^1.9.3",
"lodash": "^4.17.21",
"lucide-react": "^1.0.1",
"nova-ui-kit": "^1.1.34",
"nova-ui-kit": "^1.1.36",
"plotly.js": "^2.25.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
102 changes: 72 additions & 30 deletions ui/src/design-system/components/sort-control.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import classNames from 'classnames'
import { ArrowUpDownIcon } from 'lucide-react'
import { buttonVariants, Select } from 'nova-ui-kit'
import { ArrowDownIcon, ArrowUpDownIcon } from 'lucide-react'
import { Button, buttonVariants, Select } from 'nova-ui-kit'
import { STRING, translate } from 'utils/language'
import { TableSortSettings } from './table/types'
import { BasicTooltip } from './tooltip/basic-tooltip'

interface SortControlProps {
columns: { id: string; name: string; sortField?: string }[]
columns: {
id: string
name: string
sortField?: string
defaultSortOrder?: TableSortSettings['order']
}[]
setSort: (sort?: TableSortSettings) => void
sort?: TableSortSettings
}
Expand All @@ -16,33 +21,70 @@ export const SortControl = ({ columns, setSort, sort }: SortControlProps) => {
? columns.find((column) => column.sortField === sort.field)
: undefined

const changeSortField = (field: string) => {
const selected = columns.find((column) => column.sortField === field)
setSort({ field, order: selected?.defaultSortOrder ?? sort?.order ?? 'asc' })
}

const changeSortOrder = () => {
if (sort) {
setSort({
field: sort.field,
order: sort.order === 'asc' ? 'desc' : 'asc',
})
}
}

return (
<Select.Root
value={sort?.field}
onValueChange={(value) => {
setSort({ field: value, order: 'asc' })
}}
>
<BasicTooltip asChild content={translate(STRING.SORT_BY)}>
<Select.Trigger
className={classNames(
buttonVariants({ size: 'small', variant: 'outline' }),
'w-auto'
)}
>
<ArrowUpDownIcon className="w-4 h-4" />
<span>{column ? column.name : translate(STRING.SORT_BY)}</span>
</Select.Trigger>
</BasicTooltip>
<Select.Content>
{columns
.filter((column) => column.sortField)
.map((column) => (
<Select.Item key={column.id} value={column.sortField as string}>
{column.name}
</Select.Item>
))}
</Select.Content>
</Select.Root>
<div className="flex items-center gap-1">
<Select.Root value={sort?.field} onValueChange={changeSortField}>
<BasicTooltip asChild content={translate(STRING.SORT_BY)}>
<Select.Trigger
className={classNames(
buttonVariants({ size: 'small', variant: 'outline' }),
'w-auto'
)}
hideIcon={!!sort}
>
<span>{column ? column.name : translate(STRING.SORT_BY)}</span>
{sort ? (
<ArrowDownIcon
className={classNames(
'w-4 h-4 transition-transform duration-300',
{
'-rotate-180': sort.order !== 'asc',
'rotate-0': sort.order === 'asc',
}
)}
/>
) : null}
</Select.Trigger>
</BasicTooltip>
<Select.Content>
{columns
.filter((column) => column.sortField)
.map((column) => (
<Select.Item key={column.id} value={column.sortField as string}>
{column.name}
</Select.Item>
))}
</Select.Content>
</Select.Root>
{sort ? (
<BasicTooltip asChild content={translate(STRING.CHANGE_SORT_ORDER)}>
<Button onClick={changeSortOrder} size="icon" variant="ghost">
<ArrowUpDownIcon
className={classNames(
'w-4 h-4 transition-transform duration-300',
{
'-rotate-180': sort.order !== 'asc',
'rotate-0': sort.order === 'asc',
}
)}
/>
</Button>
</BasicTooltip>
) : null}
</div>
)
}
3 changes: 3 additions & 0 deletions ui/src/design-system/components/table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export interface TableColumn<T> {
name: string
renderCell: (item: T, rowIndex: number, columnIndex: number) => JSX.Element
sortField?: string
// Order applied when this field is first selected in the sort control. Useful
// for date-like fields (e.g. "Recent ...") that read better newest-first.
defaultSortOrder?: 'asc' | 'desc'
sticky?: boolean
styles?: {
textAlign?: TextAlign
Expand Down
2 changes: 1 addition & 1 deletion ui/src/pages/jobs/jobs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ export const Jobs = () => {
title={translate(STRING.NAV_ITEM_JOBS)}
tooltip={translate(STRING.TOOLTIP_JOB)}
>
<SortControl columns={tableColumns} setSort={setSort} sort={sort} />
{canCreate ? <NewJobDialog /> : null}
<SortControl columns={tableColumns} setSort={setSort} sort={sort} />
<ColumnSettings
columns={tableColumns}
columnSettings={columnSettings}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/pages/occurrences/occurrences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export const Occurrences = () => {
to={APP_ROUTES.EXPORTS({ projectId: projectId as string })}
>
<DownloadIcon className="w-4 h-4" />
<span>Export </span>
<span>Export</span>
</Link>
<SortControl columns={tableColumns} setSort={setSort} sort={sort} />
<ColumnSettings
Expand Down
12 changes: 6 additions & 6 deletions ui/src/pages/project-details/new-project-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,20 @@ export const NewProjectDialog = ({
const { createProject, isLoading, isSuccess, error } = useCreateProject()
const [isOpen, setIsOpen] = useState(false)

const label = translate(STRING.ENTITY_CREATE, {
type: translate(STRING.ENTITY_TYPE_PROJECT),
})

return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<Button size={buttonSize} variant={buttonVariant}>
<PlusIcon className="w-4 h-4" />
<span>{label}</span>
<span>{translate(STRING.CREATE_NEW)}</span>
</Button>
</Dialog.Trigger>
<Dialog.Content ariaCloselabel={translate(STRING.CLOSE)} isCompact>
<Dialog.Header title={label} />
<Dialog.Header
title={translate(STRING.ENTITY_CREATE, {
type: translate(STRING.ENTITY_TYPE_PROJECT),
})}
/>
<NewProjectForm
error={error}
isLoading={isLoading}
Expand Down
1 change: 0 additions & 1 deletion ui/src/pages/project/team/team-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export const columns = ({
}): TableColumn<Member>[] => [
{
id: 'user',
sortField: 'name',
name: translate(STRING.FIELD_LABEL_USER),
renderCell: (item: Member) => (
<BasicTableCell>
Expand Down
2 changes: 1 addition & 1 deletion ui/src/pages/project/team/team.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const Team = () => {
}>()
const { pagination, setPage } = usePagination()
const { sort, setSort } = useSort({
field: 'name',
field: 'created_at',
order: 'asc',
})
const { members, userPermissions, total, isLoading, isFetching, error } =
Expand Down
2 changes: 0 additions & 2 deletions ui/src/pages/projects/project-gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export const ProjectGallery = ({
return (
<Gallery
error={error}
cardSize={CardSize.Large}
isLoading={isLoading}
items={items}
renderItem={(item) => (
Expand All @@ -49,7 +48,6 @@ export const ProjectGallery = ({
/>
</Link>
)}
style={{ gridTemplateColumns: '1fr 1fr 1fr' }}
/>
)
}
Loading
Loading