diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 7d32a7545d..b5239fe60b 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -417,6 +417,8 @@
"backend": "Backend",
"region": "Region",
"instance_id": "Instance ID",
+ "schedule": "Schedule",
+ "next_run": "Next run",
"resources": "Resources",
"spot": "Spot",
"termination_reason": "Termination reason",
diff --git a/frontend/src/pages/Runs/Details/RunDetails/index.tsx b/frontend/src/pages/Runs/Details/RunDetails/index.tsx
index aab63a3edd..fae251effa 100644
--- a/frontend/src/pages/Runs/Details/RunDetails/index.tsx
+++ b/frontend/src/pages/Runs/Details/RunDetails/index.tsx
@@ -20,6 +20,7 @@ import {
getRunListItemPrice,
getRunListItemRegion,
getRunListItemResources,
+ getRunListItemSchedule,
getRunListItemServiceUrl,
getRunListItemSpot,
} from '../../List/helpers';
@@ -38,6 +39,8 @@ export const RunDetails = () => {
});
const serviceUrl = runData ? getRunListItemServiceUrl(runData) : null;
+ const schedule = runData ? getRunListItemSchedule(runData) : null;
+ const nextTriggeredAt = runData ? runData.next_triggered_at : null;
if (isLoadingRun)
return (
@@ -115,7 +118,7 @@ export const RunDetails = () => {
{t('projects.run.error')}
-
{getRunError(runData)}
+
{getRunError(runData) ?? '-'}
@@ -138,6 +141,11 @@ export const RunDetails = () => {
{getRunListItemResources(runData)}
+
+
{t('projects.run.backend')}
+
{getRunListItemBackend(runData)}
+
+
{t('projects.run.region')}
{getRunListItemRegion(runData)}
@@ -152,11 +160,6 @@ export const RunDetails = () => {
{t('projects.run.spot')}
{getRunListItemSpot(runData)}
-
-
-
{t('projects.run.backend')}
-
{getRunListItemBackend(runData)}
-
{serviceUrl && (
@@ -169,6 +172,19 @@ export const RunDetails = () => {
)}
+
+ {schedule && (
+
+
+
{t('projects.run.schedule')}
+
{schedule}
+
+
+
{t('projects.run.next_run')}
+
{nextTriggeredAt ? format(new Date(nextTriggeredAt), DATE_TIME_FORMAT) : '-'}
+
+
+ )}
{runData.run_spec.configuration.type === 'dev-environment' && !runIsStopped(runData.status) && (
diff --git a/frontend/src/pages/Runs/List/helpers.ts b/frontend/src/pages/Runs/List/helpers.ts
index e66bd69319..893a5fdbcb 100644
--- a/frontend/src/pages/Runs/List/helpers.ts
+++ b/frontend/src/pages/Runs/List/helpers.ts
@@ -14,7 +14,7 @@ export const getRunListItemResources = (run: IRun) => {
return '-';
}
- return run.latest_job_submission?.job_provisioning_data?.instance_type?.resources?.description;
+ return run.latest_job_submission?.job_provisioning_data?.instance_type?.resources?.description ?? '-';
};
export const getRunListItemSpotLabelKey = (run: IRun) => {
@@ -31,7 +31,7 @@ export const getRunListItemSpotLabelKey = (run: IRun) => {
export const getRunListItemSpot = (run: IRun) => {
if (run.jobs.length > 1) {
- return '';
+ return '-';
}
return run.latest_job_submission?.job_provisioning_data?.instance_type?.resources?.spot?.toString() ?? '-';
@@ -57,7 +57,7 @@ export const getRunListItemPrice = (run: IRun) => {
export const getRunListItemInstance = (run: IRun) => {
if (run.jobs.length > 1) {
- return '';
+ return '-';
}
return run.latest_job_submission?.job_provisioning_data?.instance_type?.name;
@@ -65,7 +65,7 @@ export const getRunListItemInstance = (run: IRun) => {
export const getRunListItemInstanceId = (run: IRun) => {
if (run.jobs.length > 1) {
- return '';
+ return '-';
}
return run.latest_job_submission?.job_provisioning_data?.instance_id ?? '-';
@@ -73,7 +73,7 @@ export const getRunListItemInstanceId = (run: IRun) => {
export const getRunListItemRegion = (run: IRun) => {
if (run.jobs.length > 1) {
- return '';
+ return '-';
}
return run.latest_job_submission?.job_provisioning_data?.region ?? '-';
@@ -81,7 +81,7 @@ export const getRunListItemRegion = (run: IRun) => {
export const getRunListItemBackend = (run: IRun) => {
if (run.jobs.length > 1) {
- return '';
+ return '-';
}
return run.latest_job_submission?.job_provisioning_data?.backend ?? '-';
@@ -92,3 +92,9 @@ export const getRunListItemServiceUrl = (run: IRun) => {
if (!url) return null;
return url.startsWith('/') ? `${getBaseUrl()}${url}` : url;
};
+
+export const getRunListItemSchedule = (run: IRun) => {
+ if (run.run_spec.configuration.type != 'task' || !run.run_spec.configuration.schedule) return null;
+
+ return run.run_spec.configuration.schedule.cron.join(', ');
+};
diff --git a/frontend/src/types/run.d.ts b/frontend/src/types/run.d.ts
index 79b20f14d0..b8e4640957 100644
--- a/frontend/src/types/run.d.ts
+++ b/frontend/src/types/run.d.ts
@@ -240,9 +240,14 @@ declare interface IJob {
job_submissions: IJobSubmission[];
}
+declare interface ISchedule {
+ cron: string[];
+}
+
declare interface ITaskConfiguration {
type: 'task';
priority?: number | null;
+ schedule?: ISchedule | null;
}
declare interface IServiceConfiguration {
@@ -250,6 +255,7 @@ declare interface IServiceConfiguration {
gateway: string | null;
priority?: number | null;
}
+
declare interface IRunSpec {
configuration: TDevEnvironmentConfiguration | ITaskConfiguration | IServiceConfiguration;
configuration_path: string;
@@ -286,6 +292,7 @@ declare interface IRun {
cost: number;
service: IRunService | null;
status_message?: string | null;
+ next_triggered_at?: string | null;
}
declare interface IMetricsItem {
diff --git a/src/dstack/_internal/core/models/runs.py b/src/dstack/_internal/core/models/runs.py
index 765888e349..0a5b174d23 100644
--- a/src/dstack/_internal/core/models/runs.py
+++ b/src/dstack/_internal/core/models/runs.py
@@ -553,6 +553,7 @@ class Run(CoreModel):
deployment_num: int = 0 # default for compatibility with pre-0.19.14 servers
error: Optional[str] = None
deleted: Optional[bool] = None
+ next_triggered_at: Optional[datetime] = None
def is_deployment_in_progress(self) -> bool:
return any(
diff --git a/src/dstack/_internal/server/services/runs.py b/src/dstack/_internal/server/services/runs.py
index c43f802fc8..25ac750aa3 100644
--- a/src/dstack/_internal/server/services/runs.py
+++ b/src/dstack/_internal/server/services/runs.py
@@ -715,6 +715,9 @@ def run_model_to_run(
status_message = _get_run_status_message(run_model)
error = _get_run_error(run_model)
fleet = _get_run_fleet(run_model)
+ next_triggered_at = None
+ if not run_model.status.is_finished():
+ next_triggered_at = _get_next_triggered_at(run_spec)
run = Run(
id=run_model.id,
project_name=run_model.project.name,
@@ -734,6 +737,7 @@ def run_model_to_run(
deployment_num=run_model.deployment_num,
error=error,
deleted=run_model.deleted,
+ next_triggered_at=next_triggered_at,
)
run.cost = _get_run_cost(run)
return run
diff --git a/src/tests/_internal/server/routers/test_runs.py b/src/tests/_internal/server/routers/test_runs.py
index be868f1e55..f4e481f539 100644
--- a/src/tests/_internal/server/routers/test_runs.py
+++ b/src/tests/_internal/server/routers/test_runs.py
@@ -517,6 +517,7 @@ def get_dev_env_run_dict(
"termination_reason": None,
"error": None,
"deleted": deleted,
+ "next_triggered_at": None,
}
@@ -665,6 +666,7 @@ async def test_lists_runs(self, test_db, session: AsyncSession, client: AsyncCli
"termination_reason": None,
"error": None,
"deleted": False,
+ "next_triggered_at": None,
},
{
"id": str(run2.id),
@@ -687,6 +689,7 @@ async def test_lists_runs(self, test_db, session: AsyncSession, client: AsyncCli
"termination_reason": None,
"error": None,
"deleted": False,
+ "next_triggered_at": None,
},
]
@@ -853,6 +856,7 @@ async def test_limits_job_submissions(
"termination_reason": None,
"error": None,
"deleted": False,
+ "next_triggered_at": None,
},
]