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
4 changes: 2 additions & 2 deletions lib/docs/assets/img/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion lib/src/blackfish/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,9 @@ def details(service_id: str) -> None: # pragma: no cover
data = {
"name": service.name,
"image": service.image,
# The full "repo:tag" this service launched with (null if it predates
# image_ref or was never launched). `ls` shows only the tag.
"image_ref": service.image_ref,
"model": service.model,
"profile": asdict(profile) if profile is not None else None,
"status": {
Expand Down Expand Up @@ -697,13 +700,14 @@ def ls(filters: Optional[str], all: bool = False) -> None: # pragma: no cover
from typing import Any
from prettytable import PrettyTable, TableStyle
from datetime import datetime
from blackfish.server.utils import format_datetime
from blackfish.server.utils import format_datetime, format_image_version
from blackfish.server.services.base import ServiceStatus

tab = PrettyTable(
field_names=[
"SERVICE ID",
"IMAGE",
"VERSION",
"MODEL",
"CREATED",
"UPDATED",
Expand Down Expand Up @@ -756,6 +760,7 @@ def is_active(service: Any) -> bool:
[
service["id"][:DISPLAY_ID_LENGTH],
service["image"],
format_image_version(service.get("image_ref")),
service["model"],
format_datetime(datetime.fromisoformat(service["created_at"])),
format_datetime(datetime.fromisoformat(service["updated_at"])),
Expand Down
3 changes: 3 additions & 0 deletions lib/src/blackfish/cli/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from blackfish.server.models.profile import deserialize_profile
from blackfish.server.utils import (
format_datetime,
format_image_version,
get_latest_commit,
get_model_dir,
get_models,
Expand Down Expand Up @@ -136,6 +137,7 @@ def list_batch_jobs(
field_names=[
"JOB ID",
"TASK",
"VERSION",
"MODEL",
"CREATED",
"UPDATED",
Expand Down Expand Up @@ -207,6 +209,7 @@ def is_active(job: Any) -> bool:
[
job["id"][:DISPLAY_ID_LENGTH],
job.get("task", ""),
format_image_version(job.get("image_ref")),
job.get("repo_id", ""),
format_datetime(datetime.fromisoformat(job["created_at"])),
format_datetime(datetime.fromisoformat(job["updated_at"])),
Expand Down
20 changes: 20 additions & 0 deletions lib/src/blackfish/server/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from huggingface_hub import ModelCard, list_repo_commits
from huggingface_hub.errors import RepositoryNotFoundError
from blackfish.server import remote
from blackfish.server.images import ImageSpec
from blackfish.server.models.profile import BlackfishProfile, SlurmProfile
from blackfish.server.logger import logger
from yaspin import yaspin
Expand Down Expand Up @@ -272,6 +273,25 @@ def find_port(
raise OSError(f"OSError: no ports available in range {lower}-{upper}")


def format_image_version(image_ref: Optional[str]) -> str:
"""Format an ``image_ref`` as a short tag for a list table.

List tables are already near the width of a standard terminal, so they show
only the tag ("0.1.1") rather than the full ``repo:tag``; the complete
reference is available in the details views.

Returns "-" when no image was recorded — the column is NULL for anything
created before the image_ref migration or never launched — and falls back to
the raw value if it isn't a well-formed reference.
"""
if not image_ref:
return "-"
try:
return ImageSpec.parse(image_ref).tag
except ValueError:
return image_ref


def format_datetime(t0: datetime.datetime, t1: datetime.datetime | None = None) -> str:
"""Format a datetime as a human-readable "… ago" string.

Expand Down
19 changes: 19 additions & 0 deletions lib/tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,25 @@ def test_find_port_none():
pass


def test_format_image_version():
"""The list tables show a tag, or a dash when nothing was recorded."""
# Well-formed refs render as the tag alone — the tables are already near
# terminal width, so the full repo:tag goes in the details views instead.
assert (
utils.format_image_version("ghcr.io/princeton-ddss/tigerflow-ml:0.1.1")
== "0.1.1"
)
assert utils.format_image_version("vllm/vllm-openai:v0.20.0") == "v0.20.0"

# NULL for rows created before image_ref existed, or never launched.
assert utils.format_image_version(None) == "-"
assert utils.format_image_version("") == "-"

# An unparsable value falls back to itself rather than raising, so a bad
# row can never break the whole table.
assert utils.format_image_version("garbage") == "garbage"


def test_format_datetime():
t1 = datetime.datetime(
2025, 1, 12, 14, 58, 29, 646404, tzinfo=datetime.timezone.utc
Expand Down
19 changes: 17 additions & 2 deletions web/src/components/ServiceSummary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CpuChipIcon,
FireIcon,
CubeTransparentIcon,
CircleStackIcon,
} from "@heroicons/react/24/outline";
import PropTypes from "prop-types";
import { formattedTimeInterval, isServiceRunning } from "@/lib/util";
Expand Down Expand Up @@ -59,6 +60,11 @@ function ServiceSummary({
<div className="grow font-regular text-sm mr-1">Model </div>
<span className="mr-2">-</span>
</div>
<div className="mb-1 ml-0 inline-flex justify-start items-center">
<CubeTransparentIcon className="h-6 w-6 text-gray-300 dark:text-gray-600 mr-1" />
<div className="grow font-regular text-sm mr-1">Image </div>
<span className="mr-2">-</span>
</div>
<div className="mb-1 ml-0 inline-flex justify-start items-center capitalize">
<HeartIcon className="h-6 w-6 text-gray-300 dark:text-gray-600 mr-1" />
<div className="grow font-regular text-sm mr-1">Status </div>
Expand All @@ -80,7 +86,7 @@ function ServiceSummary({
<span className="mr-2">-</span>
</div>
<div className="mb-1 ml-0 inline-flex items-center">
<CubeTransparentIcon className="h-6 w-6 text-gray-300 dark:text-gray-600 mr-1" />
<CircleStackIcon className="h-6 w-6 text-gray-300 dark:text-gray-600 mr-1" />
<div className="grow font-regular text-sm mr-1">Memory </div>
<span className="mr-2">-</span>
</div>
Expand Down Expand Up @@ -110,6 +116,15 @@ function ServiceSummary({
? service.model.split("/")[1] || service.model
: "-"}
</div>
<div className="mb-1 ml-0 inline-flex justify-start items-center">
<CubeTransparentIcon className="h-6 w-6 text-gray-600 dark:text-gray-400 mr-1" />
<div className="grow font-medium text-sm mr-1">Image </div>
{/* Show the tag only — a full "repo:tag" overflows this column.
The complete reference is in the title attribute. */}
<span className="service-summary__image" title={service?.image_ref || undefined}>
{service?.image_ref ? service.image_ref.split(":").pop() : "-"}
</span>
</div>
<div className="mb-1 ml-0 inline-flex justify-start items-center capitalize">
<HeartIcon className="h-6 w-6 text-gray-600 dark:text-gray-400 mr-1" />
<div className="grow font-medium text-sm mr-1">Status </div>
Expand Down Expand Up @@ -141,7 +156,7 @@ function ServiceSummary({
{service?.ntasks_per_node || "-"}
</div>
<div className="mb-1 ml-0 inline-flex items-center">
<CubeTransparentIcon className="h-6 w-6 text-gray-600 dark:text-gray-400 mr-1" />
<CircleStackIcon className="h-6 w-6 text-gray-600 dark:text-gray-400 mr-1" />
<div className="grow font-medium text-sm mr-1">Memory </div>
{service?.mem || "-"}
</div>
Expand Down
20 changes: 20 additions & 0 deletions web/src/components/ServiceSummary.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,29 @@ describe("ServiceSummary", () => {
container.querySelectorAll('.gpus-indicator')
).filter((el) => el.textContent.trim() === "-");
expect(gpus).toHaveLength(1);
// image_ref is null for services created before it was recorded, or
// never launched, so the row must degrade to a dash rather than blank.
expect(
container.querySelector('.service-summary__image').textContent.trim()
).toBe("-");
expect(baseElement).toMatchSnapshot();
});

it("renders the image tag, with the full reference as a title", () => {
// The column is narrow, so only the tag is shown; the full repo:tag is
// preserved in the title attribute for hover.
const { container } = render(
<ServiceSummary
service={{ ...mockService, image_ref: "vllm/vllm-openai:v0.20.0" }}
profile={mockProfile}
task="test-task"
/>
);
const image = container.querySelector('.service-summary__image');
expect(image.textContent.trim()).toBe("v0.20.0");
expect(image.getAttribute("title")).toBe("vllm/vllm-openai:v0.20.0");
});

describe("Timer component", () => {
it("renders Timer for running service created_at", () => {
const {baseElement, getAllByText} = render(
Expand Down
Loading
Loading