Skip to content

Commit 2102b1b

Browse files
authored
Add volume cost (#2541)
* Add Volume.cost * Show volume cost and finished time
1 parent 9474e20 commit 2102b1b

File tree

8 files changed

+157
-7
lines changed

8 files changed

+157
-7
lines changed

frontend/src/locale/en.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -496,9 +496,10 @@
496496
"region": "Region",
497497
"backend": "Backend",
498498
"status": "Status",
499-
"created_at": "Created at",
500-
"price": "Price",
501-
499+
"created": "Created",
500+
"finished": "Finished",
501+
"price": "Price (per month)",
502+
"cost": "Cost",
502503
"statuses": {
503504
"failed": "Failed",
504505
"submitted": "Submitted",

frontend/src/pages/Volumes/List/hooks.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,29 @@ export const useColumnsDefinitions = () => {
8585
),
8686
},
8787
{
88-
id: 'created_at',
89-
header: t('volume.created_at'),
88+
id: 'created',
89+
header: t('volume.created'),
9090
cell: (item: IVolume) => format(new Date(item.created_at), DATE_TIME_FORMAT),
9191
},
92+
{
93+
id: 'finished',
94+
header: t('volume.finished'),
95+
cell: (item: IVolume) => getVolumeFinised(item),
96+
},
9297
{
9398
id: 'price',
9499
header: `${t('volume.price')}`,
95100
cell: (item: IVolume) => {
96101
return item?.provisioning_data?.price ? `$${item.provisioning_data.price.toFixed(2)}` : '-';
97102
},
98103
},
104+
{
105+
id: 'cost',
106+
header: `${t('volume.cost')}`,
107+
cell: (item: IVolume) => {
108+
return item?.cost ? `$${item.cost.toFixed(2)}` : '-';
109+
},
110+
},
99111
];
100112

101113
return { columns } as const;
@@ -167,3 +179,14 @@ export const useVolumesDelete = () => {
167179

168180
return { isDeleting, deleteVolumes };
169181
};
182+
183+
const getVolumeFinised = (volume: IVolume): string => {
184+
if (!volume.deleted_at && volume.status != 'failed') {
185+
return '-';
186+
}
187+
let finished = volume.last_processed_at
188+
if (volume.deleted_at) {
189+
finished = volume.deleted_at
190+
}
191+
return format(new Date(finished), DATE_TIME_FORMAT);
192+
};

frontend/src/types/volume.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,15 @@ declare interface IVolume {
3333
project_name: string,
3434
external: boolean,
3535
created_at: string,
36+
last_processed_at: string,
3637
status: "submitted" | "provisioning" | "active" | "failed"
3738
status_message?: string
3839
deleted: boolean
40+
deleted_at?: string
3941
volume_id?: string;
4042
configuration: IVolumeConfiguration,
4143
provisioning_data: IVolumeProvisioningData
44+
cost: number
4245
attachment_data: {
4346
device_name?: string
4447
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,14 @@ class Volume(CoreModel):
104104
configuration: VolumeConfiguration
105105
external: bool
106106
created_at: datetime
107+
last_processed_at: datetime
107108
status: VolumeStatus
108109
status_message: Optional[str] = None
109110
deleted: bool
111+
deleted_at: Optional[datetime] = None
110112
volume_id: Optional[str] = None # id of the volume in the cloud
111113
provisioning_data: Optional[VolumeProvisioningData] = None
114+
cost: float = 0
112115
attachments: Optional[List[VolumeAttachment]] = None
113116
# attachment_data is deprecated in favor of attachments.
114117
# It's only set for volumes that were attached before attachments.

src/dstack/_internal/server/services/volumes.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import uuid
2-
from datetime import datetime, timezone
2+
from datetime import datetime, timedelta, timezone
33
from typing import List, Optional
44

55
from sqlalchemy import and_, func, or_, select, update
@@ -309,22 +309,29 @@ def volume_model_to_volume(volume_model: VolumeModel) -> Volume:
309309
attachment_data=get_attachment_data(volume_attachment_model),
310310
)
311311
)
312-
return Volume(
312+
deleted_at = None
313+
if volume_model.deleted_at is not None:
314+
deleted_at = volume_model.deleted_at.replace(tzinfo=timezone.utc)
315+
volume = Volume(
313316
name=volume_model.name,
314317
project_name=volume_model.project.name,
315318
user=volume_model.user.name,
316319
configuration=configuration,
317320
external=configuration.volume_id is not None,
318321
created_at=volume_model.created_at.replace(tzinfo=timezone.utc),
322+
last_processed_at=volume_model.last_processed_at.replace(tzinfo=timezone.utc),
319323
status=volume_model.status,
320324
status_message=volume_model.status_message,
321325
deleted=volume_model.deleted,
326+
deleted_at=deleted_at,
322327
volume_id=vpd.volume_id if vpd is not None else None,
323328
provisioning_data=vpd,
324329
attachments=attachments,
325330
attachment_data=vad,
326331
id=volume_model.id,
327332
)
333+
volume.cost = _get_volume_cost(volume)
334+
return volume
328335

329336

330337
def get_volume_configuration(volume_model: VolumeModel) -> VolumeConfiguration:
@@ -417,3 +424,23 @@ async def _delete_volume(session: AsyncSession, project: ProjectModel, volume_mo
417424
compute.delete_volume,
418425
volume=volume,
419426
)
427+
428+
429+
# Clouds charge volumes assuming 30-day months, e.g. https://aws.amazon.com/ebs/pricing/
430+
_VOLUME_PRICING_PERIOD = timedelta(days=30)
431+
432+
433+
def _get_volume_cost(volume: Volume) -> float:
434+
if volume.provisioning_data is None or volume.provisioning_data.price is None:
435+
return 0.0
436+
finished_at = common.get_current_datetime()
437+
if volume.deleted_at:
438+
finished_at = volume.deleted_at
439+
elif not volume.status.is_active():
440+
finished_at = volume.last_processed_at
441+
volume_age = finished_at - volume.created_at
442+
return (
443+
volume_age.total_seconds()
444+
* volume.provisioning_data.price
445+
/ _VOLUME_PRICING_PERIOD.total_seconds()
446+
)

src/dstack/_internal/server/testing/common.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,7 @@ async def create_volume(
692692
user: UserModel,
693693
status: VolumeStatus = VolumeStatus.SUBMITTED,
694694
created_at: datetime = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc),
695+
last_processed_at: Optional[datetime] = None,
695696
configuration: Optional[VolumeConfiguration] = None,
696697
volume_provisioning_data: Optional[VolumeProvisioningData] = None,
697698
deleted_at: Optional[datetime] = None,
@@ -700,12 +701,15 @@ async def create_volume(
700701
) -> VolumeModel:
701702
if configuration is None:
702703
configuration = get_volume_configuration(backend=backend, region=region)
704+
if last_processed_at is None:
705+
last_processed_at = created_at
703706
vm = VolumeModel(
704707
project=project,
705708
user_id=user.id,
706709
name=configuration.name,
707710
status=status,
708711
created_at=created_at,
712+
last_processed_at=last_processed_at,
709713
configuration=configuration.json(),
710714
volume_provisioning_data=volume_provisioning_data.json()
711715
if volume_provisioning_data
@@ -727,9 +731,11 @@ def get_volume(
727731
configuration: Optional[VolumeConfiguration] = None,
728732
external: bool = False,
729733
created_at: datetime = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc),
734+
last_processed_at: datetime = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc),
730735
status: VolumeStatus = VolumeStatus.ACTIVE,
731736
status_message: Optional[str] = None,
732737
deleted: bool = False,
738+
deleted_at: Optional[datetime] = None,
733739
volume_id: Optional[str] = None,
734740
provisioning_data: Optional[VolumeProvisioningData] = None,
735741
attachments: Optional[List[VolumeAttachment]] = None,
@@ -748,9 +754,11 @@ def get_volume(
748754
configuration=configuration,
749755
external=external,
750756
created_at=created_at,
757+
last_processed_at=last_processed_at,
751758
status=status,
752759
status_message=status_message,
753760
deleted=deleted,
761+
deleted_at=deleted_at,
754762
volume_id=volume_id,
755763
provisioning_data=provisioning_data,
756764
attachments=attachments,
@@ -777,6 +785,7 @@ def get_volume_provisioning_data(
777785
volume_id: str = "vol-1234",
778786
size_gb: int = 100,
779787
availability_zone: Optional[str] = None,
788+
price: Optional[float] = 1.0,
780789
backend_data: Optional[str] = None,
781790
backend: Optional[BackendType] = None,
782791
) -> VolumeProvisioningData:
@@ -785,6 +794,7 @@ def get_volume_provisioning_data(
785794
volume_id=volume_id,
786795
size_gb=size_gb,
787796
availability_zone=availability_zone,
797+
price=price,
788798
backend_data=backend_data,
789799
)
790800

src/tests/_internal/server/routers/test_volumes.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,14 @@ async def test_lists_volumes_across_projects(
7171
"configuration": json.loads(volume2.configuration),
7272
"external": False,
7373
"created_at": "2023-01-02T03:05:00+00:00",
74+
"last_processed_at": "2023-01-02T03:05:00+00:00",
7475
"status": "submitted",
7576
"status_message": None,
7677
"deleted": False,
78+
"deleted_at": None,
7779
"volume_id": None,
7880
"provisioning_data": None,
81+
"cost": 0.0,
7982
"attachments": [],
8083
"attachment_data": None,
8184
},
@@ -87,11 +90,14 @@ async def test_lists_volumes_across_projects(
8790
"configuration": json.loads(volume1.configuration),
8891
"external": False,
8992
"created_at": "2023-01-02T03:04:00+00:00",
93+
"last_processed_at": "2023-01-02T03:04:00+00:00",
9094
"status": "submitted",
9195
"status_message": None,
9296
"deleted": False,
97+
"deleted_at": None,
9398
"volume_id": None,
9499
"provisioning_data": None,
100+
"cost": 0.0,
95101
"attachments": [],
96102
"attachment_data": None,
97103
},
@@ -114,11 +120,14 @@ async def test_lists_volumes_across_projects(
114120
"configuration": json.loads(volume1.configuration),
115121
"external": False,
116122
"created_at": "2023-01-02T03:04:00+00:00",
123+
"last_processed_at": "2023-01-02T03:04:00+00:00",
117124
"status": "submitted",
118125
"status_message": None,
119126
"deleted": False,
127+
"deleted_at": None,
120128
"volume_id": None,
121129
"provisioning_data": None,
130+
"cost": 0.0,
122131
"attachments": [],
123132
"attachment_data": None,
124133
},
@@ -168,11 +177,14 @@ async def test_non_admin_cannot_see_others_projects(
168177
"configuration": json.loads(volume1.configuration),
169178
"external": False,
170179
"created_at": "2023-01-02T03:04:00+00:00",
180+
"last_processed_at": "2023-01-02T03:04:00+00:00",
171181
"status": "submitted",
172182
"status_message": None,
173183
"deleted": False,
184+
"deleted_at": None,
174185
"volume_id": None,
175186
"provisioning_data": None,
187+
"cost": 0.0,
176188
"attachments": [],
177189
"attachment_data": None,
178190
},
@@ -216,11 +228,14 @@ async def test_lists_volumes(self, test_db, session: AsyncSession, client: Async
216228
"configuration": json.loads(volume.configuration),
217229
"external": False,
218230
"created_at": "2023-01-02T03:04:00+00:00",
231+
"last_processed_at": "2023-01-02T03:04:00+00:00",
219232
"status": "submitted",
220233
"status_message": None,
221234
"deleted": False,
235+
"deleted_at": None,
222236
"volume_id": None,
223237
"provisioning_data": None,
238+
"cost": 0.0,
224239
"attachments": [],
225240
"attachment_data": None,
226241
}
@@ -264,11 +279,14 @@ async def test_returns_volume(self, test_db, session: AsyncSession, client: Asyn
264279
"configuration": json.loads(volume.configuration),
265280
"external": False,
266281
"created_at": "2023-01-02T03:04:00+00:00",
282+
"last_processed_at": "2023-01-02T03:04:00+00:00",
267283
"status": "submitted",
268284
"status_message": None,
269285
"deleted": False,
286+
"deleted_at": None,
270287
"volume_id": None,
271288
"provisioning_data": None,
289+
"cost": 0.0,
272290
"attachments": [],
273291
"attachment_data": None,
274292
}
@@ -326,11 +344,14 @@ async def test_creates_volume(self, test_db, session: AsyncSession, client: Asyn
326344
"user": user.name,
327345
"external": False,
328346
"created_at": "2023-01-02T03:04:00+00:00",
347+
"last_processed_at": "2023-01-02T03:04:00+00:00",
329348
"status": "submitted",
330349
"status_message": None,
331350
"deleted": False,
351+
"deleted_at": None,
332352
"volume_id": None,
333353
"provisioning_data": None,
354+
"cost": 0.0,
334355
"attachments": [],
335356
"attachment_data": None,
336357
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from datetime import datetime, timezone
2+
3+
import pytest
4+
from freezegun import freeze_time
5+
6+
from dstack._internal.core.models.volumes import VolumeStatus
7+
from dstack._internal.server.services.volumes import _get_volume_cost
8+
from dstack._internal.server.testing.common import get_volume, get_volume_provisioning_data
9+
10+
11+
class TestGetVolumeCost:
12+
def test_returns_0_when_no_provisioning_data(self):
13+
volume = get_volume(provisioning_data=None)
14+
assert _get_volume_cost(volume) == 0.0
15+
16+
def test_returns_0_when_no_price(self):
17+
volume = get_volume(
18+
provisioning_data=get_volume_provisioning_data(price=None),
19+
)
20+
assert _get_volume_cost(volume) == 0.0
21+
22+
@freeze_time(datetime(2025, 1, 31, 0, 0, tzinfo=timezone.utc))
23+
def test_calculates_active_volume_cost(self):
24+
volume = get_volume(
25+
status=VolumeStatus.ACTIVE,
26+
deleted=False,
27+
provisioning_data=get_volume_provisioning_data(price=30),
28+
created_at=datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc),
29+
)
30+
assert _get_volume_cost(volume) == pytest.approx(30.0)
31+
32+
@freeze_time(datetime(2025, 1, 31, 0, 0, tzinfo=timezone.utc))
33+
def test_calculates_finished_volume_cost(self):
34+
volume = get_volume(
35+
provisioning_data=get_volume_provisioning_data(price=30),
36+
created_at=datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc),
37+
deleted=True,
38+
deleted_at=datetime(2025, 1, 16, 0, 0, tzinfo=timezone.utc), # 15 days later
39+
)
40+
# Cost should be for 15 days out of a 30-day pricing period
41+
assert _get_volume_cost(volume) == pytest.approx(15.0)
42+
43+
@freeze_time(datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc))
44+
def test_calculates_zero_cost_for_zero_duration_active(self):
45+
volume = get_volume(
46+
status=VolumeStatus.ACTIVE,
47+
deleted=False,
48+
provisioning_data=get_volume_provisioning_data(price=30),
49+
created_at=datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc), # Same as frozen time
50+
)
51+
assert _get_volume_cost(volume) == 0.0
52+
53+
def test_calculates_zero_cost_for_zero_duration_finished(self):
54+
finished_time = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
55+
volume = get_volume(
56+
status=VolumeStatus.FAILED,
57+
deleted=False, # Can be failed without being deleted
58+
provisioning_data=get_volume_provisioning_data(price=30),
59+
created_at=finished_time,
60+
last_processed_at=finished_time,
61+
)
62+
assert _get_volume_cost(volume) == 0.0

0 commit comments

Comments
 (0)