Skip to content

Commit 5d4b862

Browse files
author
Andrey Cheptsov
committed
Add per-project templates repo support in server and UI.
Allow project settings to store and validate a templates Git repository, use it as template source with cache invalidation/fallback behavior, and surface clear launch/settings UX when templates are unavailable. Made-with: Cursor
1 parent 9ceafab commit 5d4b862

20 files changed

Lines changed: 836 additions & 180 deletions

File tree

frontend/src/locale/en.json

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"tutorial_other": "Take a tour",
5050
"docs": "Docs",
5151
"discord": "Discord",
52-
"danger_zone": "Danger Zone",
52+
"danger_zone": "Danger zone",
5353
"control_plane": "Control plane",
5454
"refresh": "Refresh",
5555
"quickstart": "Quickstart",
@@ -223,10 +223,26 @@
223223
"members_empty_message_text": "Select project's members",
224224
"update_members_success": "Members are updated",
225225
"update_visibility_success": "Project visibility updated successfully",
226+
"update_templates_repo_success": "Templates updated successfully",
226227
"update_visibility_confirm_title": "Change project visibility",
227228
"update_visibility_confirm_message": "Are you sure you want to change the project visibility? This will affect who can access this project.",
228229
"change_visibility": "Change",
229230
"project_visibility": "Visibility",
231+
"project_visibility_settings": "Change project visibility",
232+
"templates_repo": "Templates",
233+
"override_project_templates": "Configure project templates",
234+
"transfer_ownership": "Transfer ownership",
235+
"templates_repo_description": "Set a project-level templates repository URL",
236+
"templates_repo_placeholder": "https://github.com/org/templates.git",
237+
"templates_repo_not_set": "not set",
238+
"templates_repo_required": "Templates repo URL cannot be empty",
239+
"save_templates_repo": "Save",
240+
"configure_templates_repo": "Configure",
241+
"change_templates_repo_title": "Override project templates",
242+
"change_templates_repo_message": "Specify a new templates Git repo URL:",
243+
"reset_templates_repo": "Reset",
244+
"reset_templates_repo_title": "Reset templates",
245+
"reset_templates_repo_message": "Are you sure you want to reset templates for this project?",
230246
"project_visibility_description": "Control who can access this project",
231247
"make_project_public": "Make project public",
232248
"delete_project_confirm_title": "Delete project",
@@ -472,6 +488,11 @@
472488
},
473489
"runs": {
474490
"launch_button": "Launch",
491+
"no_templates_alert": {
492+
"title": "No templates configured",
493+
"description": "The selected project has no templates available for Launch.",
494+
"action": "Settings"
495+
},
475496
"launch": {
476497
"wizard": {
477498
"title": "Launch",

frontend/src/pages/Project/Details/Settings/constants.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import Link from '@cloudscape-design/components/link';
23

34
export const CLI_INFO = {
45
header: <h2>CLI</h2>,
@@ -21,3 +22,23 @@ export const CLI_INFO = {
2122
</>
2223
),
2324
};
25+
26+
export const TEMPLATES_REPO_INFO = {
27+
header: <h2>Templates</h2>,
28+
body: (
29+
<>
30+
<p>
31+
Specify a project-level templates Git repository URL. Templates from this repo are shown on the Launch page in
32+
Runs, and setting it enables the Launch button when templates are available.
33+
</p>
34+
<p>If set, project templates override global templates configured on the server.</p>
35+
<p>
36+
See official examples in{' '}
37+
<Link href="https://github.com/dstackai/dstack-templates" external>
38+
dstackai/dstack-templates
39+
</Link>
40+
.
41+
</p>
42+
</>
43+
),
44+
};

frontend/src/pages/Project/Details/Settings/index.tsx

Lines changed: 306 additions & 85 deletions
Large diffs are not rendered by default.

frontend/src/pages/Project/Details/Settings/styles.module.scss

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@
88
width: 300px;
99
}
1010

11+
.templatesRepoRow {
12+
display: flex;
13+
align-items: center;
14+
gap: 12px;
15+
}
16+
17+
.templatesRepoTitle {
18+
display: inline-flex;
19+
align-items: center;
20+
gap: 8px;
21+
}
22+
23+
.templatesRepoInput {
24+
width: 300px;
25+
max-width: 100%;
26+
}
27+
28+
.templatesRepoActions {
29+
flex-shrink: 0;
30+
}
31+
1132
.codeWrapper {
1233
position: relative;
1334

frontend/src/pages/Runs/Launch/index.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { WizardProps } from '@cloudscape-design/components';
77
import { CardsProps } from '@cloudscape-design/components/cards';
88

99
import {
10+
Button,
1011
Container,
1112
FormCards,
1213
FormCodeEditor,
@@ -234,6 +235,10 @@ export const Launch: React.FC = () => {
234235
onSubmitWizard().catch(console.log);
235236
}
236237
};
238+
const openProjectSettings = () => {
239+
if (!formValues.project) return;
240+
navigate(`${ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(formValues.project)}#danger-zone`);
241+
};
237242

238243
const envParam = selectedTemplate?.parameters?.find((p) => p.type === 'env');
239244
const yaml = useGenerateYaml({
@@ -320,6 +325,20 @@ export const Launch: React.FC = () => {
320325
},
321326
],
322327
}}
328+
empty={
329+
formValues.project ? (
330+
<SpaceBetween size="xs" direction="vertical">
331+
<div>{t('runs.no_templates_alert.description')}</div>
332+
<div>
333+
<Button formAction="none" onClick={openProjectSettings}>
334+
{t('runs.no_templates_alert.action')}
335+
</Button>
336+
</div>
337+
</SpaceBetween>
338+
) : (
339+
t('runs.launch.wizard.template_placeholder')
340+
)
341+
}
323342
cardsPerRow={[{ cards: 1 }, { minWidth: 400, cards: 2 }, { minWidth: 800, cards: 3 }]}
324343
onSelectionChange={onChangeTemplate}
325344
/>
@@ -346,11 +365,7 @@ export const Launch: React.FC = () => {
346365
defaultValue={false}
347366
toggleLabel={t('runs.launch.wizard.gpu')}
348367
toggleDescription={t('runs.launch.wizard.gpu_description')}
349-
errorText={
350-
formValues.gpu_enabled
351-
? formState.errors.offer?.message
352-
: undefined
353-
}
368+
errorText={formValues.gpu_enabled ? formState.errors.offer?.message : undefined}
354369
name={FORM_FIELD_NAMES.gpu_enabled}
355370
/>
356371
}
@@ -371,9 +386,7 @@ export const Launch: React.FC = () => {
371386
control={control}
372387
label={t('runs.launch.wizard.configuration_label')}
373388
description={t('runs.launch.wizard.configuration_description')}
374-
info={
375-
<InfoLink onFollow={() => openHelpPanel(CONFIGURATION_INFO)} />
376-
}
389+
info={<InfoLink onFollow={() => openHelpPanel(CONFIGURATION_INFO)} />}
377390
name={FORM_FIELD_NAMES.config_yaml}
378391
language="yaml"
379392
loading={loading}

frontend/src/pages/Runs/List/index.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ export const RunList: React.FC = () => {
5050
filteringStatusType,
5151
handleLoadItems,
5252
} = useFilters();
53-
5453
const projectHavingFleetMap = useCheckingForFleetsInProjects({});
5554

5655
const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll<IRun, TRunsRequestParams>({
@@ -120,7 +119,6 @@ export const RunList: React.FC = () => {
120119
}`,
121120
);
122121
};
123-
124122
const projectDontHasFleet = Object.keys(projectHavingFleetMap).find((project) => !projectHavingFleetMap[project]);
125123

126124
return (
@@ -143,7 +141,6 @@ export const RunList: React.FC = () => {
143141
show={!!projectDontHasFleet}
144142
dismissible={true}
145143
/>
146-
147144
<Header
148145
variant="awsui-h1-sticky"
149146
actions={

frontend/src/services/project.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,14 @@ export const projectApi = createApi({
194194
providesTags: () => ['ProjectRepos'],
195195
}),
196196

197-
updateProject: builder.mutation<IProject, { project_name: string; is_public: boolean }>({
198-
query: ({ project_name, is_public }) => ({
197+
updateProject: builder.mutation<
198+
IProject,
199+
{ project_name: string; is_public?: boolean; templates_repo?: string | null }
200+
>({
201+
query: ({ project_name, ...body }) => ({
199202
url: API.PROJECTS.UPDATE(project_name),
200203
method: 'POST',
201-
body: { is_public },
204+
body,
202205
}),
203206
transformResponse: transformProjectResponse,
204207
invalidatesTags: (result, error, params) => [{ type: 'Projects' as const, id: params?.project_name }],

frontend/src/types/project.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ declare interface IProject {
3030
owner: IUser | { username: string };
3131
created_at: string;
3232
isPublic: boolean;
33+
templates_repo?: string | null;
3334
}
3435

3536
declare interface IProjectMember {
@@ -55,4 +56,5 @@ declare interface IProjectSecret {
5556

5657
declare type IProjectCreateRequestParams = Pick<IProject, 'project_name'> & {
5758
is_public: boolean;
59+
templates_repo?: string | null;
5860
};

src/dstack/_internal/core/models/projects.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Project(CoreModel):
2626
backends: List[BackendInfo]
2727
members: List[Member]
2828
is_public: bool = False
29+
templates_repo: Optional[str] = None
2930

3031

3132
class ProjectsInfoList(CoreModel):
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Add ProjectModel.templates_repo
2+
3+
Revision ID: a13f5b55af01
4+
Revises: 5e8c7a9202bc
5+
Create Date: 2026-03-06 12:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "a13f5b55af01"
14+
down_revision = "c7b0a8e57294"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
with op.batch_alter_table("projects", schema=None) as batch_op:
21+
batch_op.add_column(sa.Column("templates_repo", sa.Text(), nullable=True))
22+
23+
24+
def downgrade() -> None:
25+
with op.batch_alter_table("projects", schema=None) as batch_op:
26+
batch_op.drop_column("templates_repo")

0 commit comments

Comments
 (0)