Skip to content
Merged
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
151 changes: 139 additions & 12 deletions gateway/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
from django.contrib import admin, messages
from django.core.cache import cache
from django.db.models import Count, F, Q
from django.utils.html import format_html
from django.utils import timezone
from django.utils.html import format_html, format_html_join
from django.utils.http import urlencode
from django.utils.safestring import mark_safe
from django.urls import path, reverse
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.admin.views.main import PAGE_VAR
Expand Down Expand Up @@ -441,6 +444,24 @@ class Media: # pylint: disable=too-few-public-methods
css = {"all": ["admin/css/admin_job_event_inline.css"]}


CODE_CHIP_MAX_LENGTH = 12


def _short_datetime(value):
"""Compact local timestamp for the changelist, YY/MM/DD hh:mm:ss, so the date columns stay narrow."""
if value is None:
return ""
return timezone.localtime(value).strftime("%y/%m/%d %H:%M:%S")


def _code_chip(value):
"""A short monospace chip showing at most CODE_CHIP_MAX_LENGTH characters; the full value is the title."""
display_value = value[:CODE_CHIP_MAX_LENGTH]
if len(value) > CODE_CHIP_MAX_LENGTH:
display_value += "…"
return format_html('<span class="qs-runner-id" title="{}">{}</span>', value, display_value)


class JobProgramFilter(admin.SimpleListFilter):
"""Filter jobs by provider / program."""

Expand Down Expand Up @@ -471,10 +492,35 @@ def queryset(self, request, queryset):
class JobAdmin(admin.ModelAdmin):
"""JobAdmin."""

search_fields = ["id", "author__username", "program__title"]
search_fields = [
"id",
"author__username",
"program__title",
"program__provider__name",
"fleet_id",
"compute_profile_fk__compute_profile_id",
"status",
"instance_crn",
]
list_filter = ["status", "runner", "filler", JobProgramFilter]
list_display = ["runner", "author", "get_program", "status_badge", "created", "updated"]
list_select_related = ["author", "program", "program__provider"]
list_display = [
"id_column",
"author_column",
"get_program",
"status_badge",
"runner_column",
"compute_profile_column",
"created_column",
"updated_column",
]
list_display_links = ["id_column"]
list_select_related = [
"author",
"program",
"program__provider",
"compute_profile_fk",
"function_size",
]
ordering = ["-created"]
actions = ["timeline_action"]
inlines = []
Expand Down Expand Up @@ -524,6 +570,10 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs):
formfield.widget.can_delete_related = False
return formfield

def _search_link(self, value):
"""Changelist URL that searches for value, so the search box shows what's filtered."""
return f"{reverse('admin:api_job_changelist')}?{urlencode({'q': value})}"

@admin.action(description="Timeline")
def timeline_action(self, request, queryset):
"""Redirect to the Gantt/concurrency timeline for the selected jobs.
Expand Down Expand Up @@ -637,23 +687,100 @@ def job_events_view(self, request, job_id):
}
return render(request, "admin/api/job/events.html", context)

class Media:
js = ["admin/js/clickable_rows.js"]
@admin.display(description="Id")
def id_column(self, obj):
"""Show the job UUID as a short code chip; list_display_links turns it into the link to the job page."""
return _code_chip(str(obj.pk))

@admin.display(description="Fleet Id")
def runner_column(self, obj):
"""Engine job id as a code chip, with the CE project/region for Fleets and the engine name for Ray below it."""
is_fleets = obj.runner == Program.FLEETS
engine_job_id = obj.fleet_id if is_fleets else obj.ray_job_id
lines = []
if engine_job_id:
lines.append(_code_chip(engine_job_id))
if is_fleets:
# The column header already says Fleets, so only Ray jobs need the engine spelled out.
project_and_region = " ".join(part for part in [obj.ce_project_name, obj.ce_region] if part)
if project_and_region:
lines.append(format_html('<span class="qs-runner-meta">{}</span>', project_and_region))
else:
lines.append(format_html('<span class="qs-runner-label">{}</span>', obj.get_runner_display()))
return format_html_join(mark_safe("<br>"), "{}", ((line,) for line in lines))

@admin.display(description="Created", ordering="created")
def created_column(self, obj):
"""Creation timestamp in the compact changelist format."""
return _short_datetime(obj.created)

@admin.display(description="Updated", ordering="updated")
def updated_column(self, obj):
"""Last-update timestamp in the compact changelist format."""
return _short_datetime(obj.updated)

@admin.display(description="Status")
def status_badge(self, obj):
"""Render status as a colored badge."""
return format_html('<span class="qs-status-badge" data-status="{}">{}</span>', obj.status, obj.status)
"""Render status as a colored badge; clicking it searches the changelist for that status."""
return format_html(
'<a href="{}" class="qs-status-badge" data-status="{}">{}</a>',
self._search_link(obj.status),
obj.status,
obj.status,
)

@admin.display(description="Author")
def author_column(self, obj):
"""Link the author's name to a changelist search for them, instance CRN below (same search)."""
lines = [
format_html('<a href="{}" class="qs-cell-link">{}</a>', self._search_link(obj.author.username), obj.author)
]
if obj.instance_crn:
lines.append(
format_html(
'<a href="{}" class="qs-runner-meta">{}</a>', self._search_link(obj.instance_crn), obj.instance_crn
)
)
return format_html_join(mark_safe("<br>"), "{}", ((line,) for line in lines))

@admin.display(description="Compute Profile")
def compute_profile_column(self, obj):
"""Fleets compute profile, clicking it searches the changelist for it; function size below. Empty for Ray."""
if obj.runner != Program.FLEETS or obj.compute_profile_fk is None:
return ""
lines = [
format_html(
'<a href="{}" class="qs-cell-link">{}</a>',
self._search_link(obj.compute_profile_fk_id),
obj.compute_profile_fk,
)
]
if obj.function_size is not None:
lines.append(format_html('<span class="qs-runner-meta">{}</span>', obj.function_size.function_size))
return format_html_join(mark_safe("<br>"), "{}", ((line,) for line in lines))

@admin.display(description="Program")
def get_program(self, obj):
"""Return provider / program label for list display."""
"""Function name, with its provider below it, or "Custom" when the function has no provider."""
if obj.program is None:
return "-"
lines = [
format_html(
'<a href="{}" class="qs-cell-link">{}</a>', self._search_link(obj.program.title), obj.program.title
)
]
provider = obj.program.provider
if provider:
return f"{provider.name} / {obj.program.title}"
return obj.program.title
if provider is None:
lines.append(mark_safe('<span class="qs-runner-meta">Custom</span>'))
else:
lines.append(
format_html(
'<span class="qs-runner-meta">Provider: <a href="{}">{}</a></span>',
self._search_link(provider.name),
provider.name,
)
)
return format_html_join(mark_safe("<br>"), "{}", ((line,) for line in lines))

def save_model(self, request, obj, form, change):
if change:
Expand Down
45 changes: 45 additions & 0 deletions gateway/api/migrations/0064_job_fleet_id_idx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models


class AddIndexConcurrentlyOrPlain(AddIndexConcurrently):
"""AddIndexConcurrently, but falls back to a plain (locking) AddIndex off PostgreSQL.

django.contrib.postgres.operations.AddIndexConcurrently always calls
schema_editor.add_index(model, index, concurrently=True), which the SQLite backend used by
the test suite doesn't accept at all. CONCURRENTLY only matters for a live PostgreSQL table
anyway, so a plain index add is a fine substitute everywhere else.
"""

def database_forwards(self, app_label, schema_editor, from_state, to_state):
if schema_editor.connection.vendor != "postgresql":
model = to_state.apps.get_model(app_label, self.model_name)
if self.allow_migrate_model(schema_editor.connection.alias, model):
schema_editor.add_index(model, self.index)
return
super().database_forwards(app_label, schema_editor, from_state, to_state)

def database_backwards(self, app_label, schema_editor, from_state, to_state):
if schema_editor.connection.vendor != "postgresql":
model = from_state.apps.get_model(app_label, self.model_name)
if self.allow_migrate_model(schema_editor.connection.alias, model):
schema_editor.remove_index(model, self.index)
return
super().database_backwards(app_label, schema_editor, from_state, to_state)


class Migration(migrations.Migration):

# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
atomic = False

dependencies = [
("api", "0063_merge_20260902_1720"),
]

operations = [
AddIndexConcurrentlyOrPlain(
model_name="job",
index=models.Index(fields=["fleet_id"], name="job_fleet_id_idx"),
),
]
88 changes: 81 additions & 7 deletions gateway/api/static/admin/css/carbon_theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ html[data-theme="light"],
--qs-code-bg: #161616;
--qs-code-fg: #f4f4f4;
--qs-code-border: #393939;
--qs-chip-bg: #e0e0e0;
--qs-chip-fg: #161616;
--qs-chip-border: #c6c6c6;

--font-family-primary:
"IBM Plex Sans",
Expand Down Expand Up @@ -161,6 +164,9 @@ html[data-theme="light"],
--qs-code-bg: #262626;
--qs-code-fg: #f4f4f4;
--qs-code-border: #525252;
--qs-chip-bg: #393939;
--qs-chip-fg: #f4f4f4;
--qs-chip-border: #525252;
}
}

Expand Down Expand Up @@ -211,6 +217,9 @@ html[data-theme="dark"] {
--qs-code-bg: #262626;
--qs-code-fg: #f4f4f4;
--qs-code-border: #525252;
--qs-chip-bg: #393939;
--qs-chip-fg: #f4f4f4;
--qs-chip-border: #525252;
}

/* BASE */
Expand Down Expand Up @@ -408,10 +417,6 @@ tr:nth-child(odd) + .row-form-errors .errorlist {
background: transparent;
}

#result_list tbody tr {
cursor: pointer;
}

#result_list tbody tr:hover {
background: var(--qs-row-hover);
}
Expand All @@ -430,6 +435,13 @@ thead th.sorted {
font-size: 0.8125rem;
}

/* JOB LIST — created/updated timestamps are secondary, so the same small size as the cells' second lines */
#result_list .field-created_column,
#result_list .field-updated_column {
font-size: 0.6875rem;
white-space: nowrap;
}

/* BUTTONS — softly rounded, generous horizontal padding */

.button, input[type=submit], input[type=button], .submit-row input, a.button {
Expand Down Expand Up @@ -912,6 +924,8 @@ body.login #content h1 {

/* JOB LIST — status badges (same palette as event-badge inline) */

/* !important: this is an <a> now (clicking it searches by status), and Django's own
a:link/a:visited rules are more specific than a single class, so they'd otherwise win. */
.qs-status-badge {
display: inline-block;
padding: 2px 9px;
Expand All @@ -920,26 +934,86 @@ body.login #content h1 {
font-weight: 600;
letter-spacing: 0.32px;
text-transform: uppercase;
color: #ffffff;
text-decoration: none;
color: #ffffff !important;
background-color: var(--qs-badge-bg, #6f6f6f);
}

.qs-status-badge[data-status="QUEUED"] { --qs-badge-bg: #8a3ffc; }

.qs-status-badge[data-status="PENDING"] {
--qs-badge-bg: #f0ad4e;
color: #1c1c1c;
color: #1c1c1c !important;
}

.qs-status-badge[data-status="RUNNING"] {
--qs-badge-bg: #5bc0de;
color: #1c1c1c;
color: #1c1c1c !important;
}

.qs-status-badge[data-status="SUCCEEDED"] { --qs-badge-bg: #00aa00; }
.qs-status-badge[data-status="STOPPED"] { --qs-badge-bg: #888888; }
.qs-status-badge[data-status="FAILED"] { --qs-badge-bg: #cc0000; }

/* JOB LIST — Id and Fleet Id columns (short, fixed-length code chips; the full value is the title) */

.qs-runner-id {
display: inline-block;
white-space: nowrap;
vertical-align: bottom;
font-family: var(--font-family-monospace);
font-size: 0.75rem;
color: var(--qs-chip-fg);
background: var(--qs-chip-bg);
border: 1px solid var(--qs-chip-border);
border-radius: 4px;
padding: 1px 5px;
}

.qs-runner-label {
display: block;
font-size: 0.6875rem;
color: #6f6f6f;
text-transform: uppercase;
letter-spacing: 0.32px;
}

/* Secondary text lines (CRN, CE project/region, function size): never wrap, ellipsis instead */
.qs-runner-meta {
display: block;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.6875rem;
color: #6f6f6f;
}

a.qs-runner-meta,
.qs-runner-meta a {
text-decoration: none;
}

a.qs-runner-meta:hover,
.qs-runner-meta a:hover {
text-decoration: underline;
}

/* Author, Program/provider and Compute Profile links: never wrap, ellipsis instead, underline on hover */
.qs-cell-link {
display: inline-block;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
text-decoration: none;
}

a.qs-cell-link:hover {
text-decoration: underline;
}

/* NAV SIDEBAR — hide +Add links */

#nav-sidebar .addlink {
Expand Down
Loading