Skip to content
Closed
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
207 changes: 207 additions & 0 deletions areno/dashboard/dist/assets/index-BeP2LTzY.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions areno/dashboard/dist/assets/index-DV7XCEqO.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions areno/dashboard/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AReno Dashboard</title>
<script type="module" crossorigin src="./assets/index-DI2APD7b.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CjKYAYYQ.css">
<script type="module" crossorigin src="./assets/index-BeP2LTzY.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DV7XCEqO.css">
</head>
<body>
<div id="root"></div>
Expand Down
70 changes: 68 additions & 2 deletions areno/dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,59 @@ def now() -> str:
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()


def _parse_time(value: Any) -> float | None:
"""Parse an ISO-8601 timestamp or epoch float to epoch seconds."""
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return dt.datetime.fromisoformat(value).timestamp()
except ValueError:
return None
return None


def _duration_seconds(created_at: Any, updated_at: Any, status: str = "") -> float | None:
"""Return the elapsed seconds for a job.

For running jobs, duration is measured to now() (not updated_at) so it
keeps growing while the job is alive. For terminal jobs, duration is
measured to updated_at (which is set when the job transitions to a
terminal state). This avoids the problem where registry scans update
updated_at every poll cycle even after a job has exited.
"""
start = _parse_time(created_at)
if start is None:
return None
if status == "running":
return max(0.0, _parse_time(now()) - start)
end = _parse_time(updated_at)
if end is None:
return None
return max(0.0, end - start)


def _launch_value(launch: Any, key: str, default: Any = "") -> Any:
"""Read a value from launch_config, handling both flat dict and sections format."""
if not isinstance(launch, dict):
return default
if key in launch:
return launch[key] or default
sections = launch.get("sections")
if not isinstance(sections, list):
return default
for section in sections:
if not isinstance(section, dict):
continue
items = section.get("items")
if not isinstance(items, list):
continue
for item in items:
if isinstance(item, dict) and item.get("key") == key:
return item.get("value", default)
return default


class Job:
def __init__(
self,
Expand Down Expand Up @@ -157,6 +210,7 @@ def to_json(self) -> dict[str, Any]:
}

def to_summary_json(self) -> dict[str, Any]:
launch = self.launch_config or {}
return {
"id": self.id,
"kind": self.kind,
Expand All @@ -169,9 +223,13 @@ def to_summary_json(self) -> dict[str, Any]:
"step": self.step,
"created_at": self.created_at,
"updated_at": self.updated_at,
"duration_s": _duration_seconds(self.created_at, self.updated_at, self.status),
"returncode": self.returncode,
"pid": self.pid,
"perf": self.perf,
"algo": _launch_value(launch, "algo"),
"ckpt": _launch_value(launch, "ckpt") or _launch_value(launch, "model_path"),
"dataset_path": _launch_value(launch, "dataset_path"),
}


Expand Down Expand Up @@ -464,7 +522,7 @@ def list_jobs(self) -> list[dict[str, Any]]:
self._save_state()
return [
job.to_summary_json()
for job in sorted(self.jobs.values(), key=lambda item: item.created_at, reverse=True)
for job in sorted(self.jobs.values(), key=lambda item: _parse_time(item.created_at) or 0, reverse=True)
]

def _refresh_job_status(self, job: Job) -> None:
Expand Down Expand Up @@ -564,6 +622,8 @@ def scan_registered_jobs(self) -> None:
pid=pid,
cwd=str(item.get("cwd") or "") or None,
)
if item.get("created_at"):
job.created_at = item["created_at"]
job.launch_config = dict(job.config)
job.config = {}
job.status = "running" if is_running else "exited"
Expand All @@ -579,9 +639,15 @@ def scan_registered_jobs(self) -> None:
job.config = dict(item.get("config") or {})
if item.get("config") and not job.launch_config:
job.launch_config = dict(item.get("config") or {})
old_status = job.status
if job.status != "stopped":
job.status = "running" if is_running else "exited"
job.updated_at = now()
# Only update updated_at when transitioning to a terminal
# state or while still running. Do not touch it for jobs
# that were already in a terminal state — otherwise the
# duration keeps growing on every scan.
if is_running or (old_status == "running" and not is_running):
job.updated_at = now()
self._save_state()

def _load_state(self) -> None:
Expand Down
150 changes: 145 additions & 5 deletions dashboard/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
Plus,
Play,
RefreshCw,
RotateCcw,
Search,
Send,
Server,
Settings2,
Expand Down Expand Up @@ -313,6 +315,53 @@ const defaultServeConfig = {
extra_args: "",
};

const SORT_OPTIONS = [
{ value: "newest", label: "Start time (newest)" },
{ value: "oldest", label: "Start time (oldest)" },
{ value: "duration_desc", label: "Duration (longest)" },
{ value: "duration_asc", label: "Duration (shortest)" },
];

const STATE_OPTIONS = ["all", "running", "succeeded", "failed", "stopped", "exited", "unknown"];
const TYPE_OPTIONS = ["all", "train", "serve"];
const VALID_STATES = new Set(STATE_OPTIONS);
const VALID_TYPES = new Set(TYPE_OPTIONS);
const VALID_SORTS = new Set(SORT_OPTIONS.map((o) => o.value));

function readUrlParam(key, fallback) {
const params = new URLSearchParams(window.location.search);
return params.get(key) || fallback;
}

function readUrlEnum(key, fallback, valid) {
const raw = readUrlParam(key, fallback);
return valid.has(raw) ? raw : fallback;
}

function readUrlPage(fallback = 1) {
const raw = readUrlParam("page", "");
if (!raw) return fallback;
const n = Number(raw);
if (!Number.isFinite(n) || n < 1) return fallback;
return Math.floor(n);
}

function syncUrlParams(params) {
const url = new URL(window.location.href);
url.search = "";
for (const [key, value] of Object.entries(params)) {
if (value) url.searchParams.set(key, value);
}
window.history.replaceState({}, "", url.toString());
}

function jobTimestamp(job, field) {
const v = job[field];
if (typeof v === "number") return v;
if (typeof v === "string") return Date.parse(v) || 0;
return 0;
}

function App() {
const [selectedJobId, setSelectedJobId] = useState(null);
const [trainConfig, setTrainConfig] = useState(defaultTrainConfig);
Expand All @@ -335,7 +384,12 @@ function App() {
const [language, setLanguage] = useState(() => localStorage.getItem(UI_LANGUAGE_STORAGE_KEY) || "en");
const [busy, setBusy] = useState("");
const [agentSettingsOpen, setAgentSettingsOpen] = useState(false);
const [jobPage, setJobPage] = useState(1);
const [jobPage, setJobPage] = useState(() => readUrlPage(1));
const [searchQuery, setSearchQuery] = useState(() => readUrlParam("q", ""));
const [stateFilter, setStateFilter] = useState(() => readUrlEnum("state", "all", VALID_STATES));
const [typeFilter, setTypeFilter] = useState(() => readUrlEnum("type", "all", VALID_TYPES));
const [algoFilter, setAlgoFilter] = useState(() => readUrlParam("algo", "all"));
const [sortKey, setSortKey] = useState(() => readUrlEnum("sort", "newest", VALID_SORTS));
const [jobDetailOpen, setJobDetailOpen] = useState(false);
const [refreshNonce, setRefreshNonce] = useState(0);
const [followUpsLoading, setFollowUpsLoading] = useState(false);
Expand Down Expand Up @@ -367,10 +421,52 @@ function App() {
[selectedJobId],
);

const jobList = (jobs.data?.jobs || []).filter((job) => job.kind !== "runtime-repair");
const jobList = (jobs.data?.jobs || []).filter((job) => job.kind !== "runtime-repair");
const runtimeRepairJob = runtimeRepairDetail.data?.job || runtimeRepair?.job || null;
const filteredJobs = jobFilter === "all" ? jobList : jobList.filter((job) => job.status === jobFilter);
const jobPageSize = 3;

const algoOptions = useMemo(() => {
const algos = Array.from(new Set(jobList.map((job) => job.algo).filter(Boolean))).sort();
return ["all", ...algos];
}, [jobList]);

const filteredJobs = useMemo(() => {
let result = jobList;
if (stateFilter !== "all") result = result.filter((job) => job.status === stateFilter);
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter((job) =>
[job.id, job.name, job.ckpt, job.dataset_path].some((field) =>
String(field || "").toLowerCase().includes(q)
)
);
}
if (typeFilter !== "all") result = result.filter((job) => job.kind === typeFilter);
if (algoFilter !== "all") result = result.filter((job) => job.algo === algoFilter);

const sorted = [...result];
if (sortKey === "newest") {
sorted.sort((a, b) => jobTimestamp(b, "created_at") - jobTimestamp(a, "created_at"));
} else if (sortKey === "oldest") {
sorted.sort((a, b) => jobTimestamp(a, "created_at") - jobTimestamp(b, "created_at"));
} else if (sortKey === "duration_desc") {
sorted.sort((a, b) => (Number(b.duration_s) || 0) - (Number(a.duration_s) || 0));
} else if (sortKey === "duration_asc") {
sorted.sort((a, b) => (Number(a.duration_s) || 0) - (Number(b.duration_s) || 0));
}
return sorted;
}, [jobList, searchQuery, stateFilter, typeFilter, algoFilter, sortKey]);

const hasActiveFilters = searchQuery || stateFilter !== "all" || typeFilter !== "all" || algoFilter !== "all" || sortKey !== "newest";

function resetFilters() {
setSearchQuery("");
setStateFilter("all");
setTypeFilter("all");
setAlgoFilter("all");
setSortKey("newest");
setJobPage(1);
}
const jobPageCount = Math.max(1, Math.ceil(filteredJobs.length / jobPageSize));
const currentJobPage = Math.min(jobPage, jobPageCount);
const pagedJobs = filteredJobs.slice((currentJobPage - 1) * jobPageSize, currentJobPage * jobPageSize);
Expand All @@ -397,8 +493,21 @@ function App() {
}, [jobList.length, selectedJobId]);

useEffect(() => {
if (jobPage > jobPageCount) setJobPage(jobPageCount);
}, [jobPage, jobPageCount]);
// Only clamp page after jobs have loaded — otherwise jobPageCount=1
// on initial render would wipe a restored page=2 before data arrives.
if (jobList.length > 0 && jobPage > jobPageCount) setJobPage(jobPageCount);
}, [jobPage, jobPageCount, jobList.length]);

useEffect(() => {
syncUrlParams({
q: searchQuery,
state: stateFilter !== "all" ? stateFilter : "",
type: typeFilter !== "all" ? typeFilter : "",
algo: algoFilter !== "all" ? algoFilter : "",
sort: sortKey !== "newest" ? sortKey : "",
page: jobPage > 1 ? String(jobPage) : "",
});
}, [searchQuery, stateFilter, typeFilter, algoFilter, sortKey, jobPage]);

useEffect(() => {
const status = String(runtimeRepairJob?.status || "").toLowerCase();
Expand Down Expand Up @@ -997,6 +1106,36 @@ function App() {
</div>
</div>
</div>
{jobList.length > 0 && (
<div className="jobToolbar">
<div className="jobSearch">
<Search size={14} />
<input
type="text"
placeholder="Search by ID, model, or dataset..."
value={searchQuery}
onChange={(event) => { setSearchQuery(event.target.value); setJobPage(1); }}
/>
</div>
<div className="jobFilters">
<select value={stateFilter} onChange={(event) => { setStateFilter(event.target.value); setJobPage(1); }}>
{STATE_OPTIONS.map((s) => <option key={s} value={s}>{s === "all" ? "All states" : s}</option>)}
</select>
<select value={typeFilter} onChange={(event) => { setTypeFilter(event.target.value); setJobPage(1); }}>
{TYPE_OPTIONS.map((t) => <option key={t} value={t}>{t === "all" ? "All types" : t}</option>)}
</select>
<select value={algoFilter} onChange={(event) => { setAlgoFilter(event.target.value); setJobPage(1); }}>
{algoOptions.map((a) => <option key={a} value={a}>{a === "all" ? "All algorithms" : a}</option>)}
</select>
<select value={sortKey} onChange={(event) => setSortKey(event.target.value)}>
{SORT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<button className="secondaryButton" onClick={resetFilters} disabled={!hasActiveFilters}>
<RotateCcw size={14} /> Reset
</button>
</div>
</div>
)}
<div className="jobTableWrap">
{filteredJobs.length === 0 && <EmptyState title="No matching jobs" text="Start a task from Launcher or select another status." />}
{filteredJobs.length > 0 && <table className="jobTable">
Expand All @@ -1018,6 +1157,7 @@ function App() {
{filteredJobs.length > jobPageSize && (
<div className="listFooter">
Showing {(currentJobPage - 1) * jobPageSize + 1}-{Math.min(currentJobPage * jobPageSize, filteredJobs.length)} of {filteredJobs.length} jobs
{jobList.length > filteredJobs.length && ` (filtered from ${jobList.length})`}
</div>
)}
</section>
Expand Down
49 changes: 49 additions & 0 deletions dashboard/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,55 @@ p {
min-width: 0;
}

.jobToolbar {
display: grid;
gap: 10px;
margin-bottom: 12px;
}

.jobSearch {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--deck-line);
border-radius: var(--radius-sm);
padding: 0 10px;
background: var(--deck-panel-2);
color: var(--deck-muted);
}

.jobSearch input {
flex: 1;
border: 0;
background: transparent;
color: var(--deck-ink);
font-size: 13px;
padding: 8px 0;
outline: none;
}

.jobSearch input::placeholder {
color: var(--deck-muted);
}

.jobFilters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}

.jobFilters select {
min-height: 34px;
border-radius: var(--radius-sm);
font-size: 12px;
}

.jobFilters .secondaryButton:disabled {
opacity: 0.42;
cursor: not-allowed;
}

.jobList {
display: grid;
gap: 7px;
Expand Down
Loading