dstack 0.19.15__py3-none-any.whl → 0.19.16__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of dstack might be problematic. Click here for more details.
- dstack/_internal/core/backends/cloudrift/__init__.py +0 -0
- dstack/_internal/core/backends/cloudrift/api_client.py +208 -0
- dstack/_internal/core/backends/cloudrift/backend.py +16 -0
- dstack/_internal/core/backends/cloudrift/compute.py +138 -0
- dstack/_internal/core/backends/cloudrift/configurator.py +66 -0
- dstack/_internal/core/backends/cloudrift/models.py +40 -0
- dstack/_internal/core/backends/configurators.py +9 -0
- dstack/_internal/core/backends/models.py +7 -0
- dstack/_internal/core/compatibility/logs.py +15 -0
- dstack/_internal/core/compatibility/runs.py +2 -0
- dstack/_internal/core/models/backends/base.py +2 -0
- dstack/_internal/core/models/configurations.py +22 -2
- dstack/_internal/core/models/logs.py +2 -1
- dstack/_internal/core/models/runs.py +10 -1
- dstack/_internal/server/background/tasks/process_fleets.py +1 -1
- dstack/_internal/server/background/tasks/process_gateways.py +1 -1
- dstack/_internal/server/background/tasks/process_instances.py +1 -1
- dstack/_internal/server/background/tasks/process_placement_groups.py +1 -1
- dstack/_internal/server/background/tasks/process_running_jobs.py +1 -1
- dstack/_internal/server/background/tasks/process_runs.py +21 -2
- dstack/_internal/server/background/tasks/process_submitted_jobs.py +10 -4
- dstack/_internal/server/background/tasks/process_terminating_jobs.py +2 -2
- dstack/_internal/server/background/tasks/process_volumes.py +1 -1
- dstack/_internal/server/routers/gateways.py +6 -3
- dstack/_internal/server/routers/projects.py +63 -0
- dstack/_internal/server/routers/prometheus.py +5 -5
- dstack/_internal/server/schemas/logs.py +10 -1
- dstack/_internal/server/schemas/projects.py +12 -0
- dstack/_internal/server/security/permissions.py +75 -2
- dstack/_internal/server/services/fleets.py +1 -1
- dstack/_internal/server/services/gateways/__init__.py +1 -1
- dstack/_internal/server/services/jobs/configurators/base.py +7 -1
- dstack/_internal/server/services/logs/aws.py +38 -38
- dstack/_internal/server/services/logs/filelog.py +48 -14
- dstack/_internal/server/services/logs/gcp.py +17 -16
- dstack/_internal/server/services/projects.py +164 -5
- dstack/_internal/server/services/prometheus/__init__.py +0 -0
- dstack/_internal/server/services/prometheus/client_metrics.py +52 -0
- dstack/_internal/server/services/runs.py +3 -3
- dstack/_internal/server/services/services/__init__.py +2 -1
- dstack/_internal/server/services/users.py +1 -3
- dstack/_internal/server/services/volumes.py +1 -1
- dstack/_internal/server/statics/index.html +1 -1
- dstack/_internal/server/statics/{main-0ac1e1583684417ae4d1.js → main-a4eafa74304e587d037c.js} +51 -43
- dstack/_internal/server/statics/{main-0ac1e1583684417ae4d1.js.map → main-a4eafa74304e587d037c.js.map} +1 -1
- dstack/_internal/server/statics/{main-f39c418b05fe14772dd8.css → main-f53d6d0d42f8d61df1de.css} +1 -1
- dstack/_internal/settings.py +1 -0
- dstack/api/_public/runs.py +6 -5
- dstack/api/server/_logs.py +5 -1
- dstack/api/server/_projects.py +24 -0
- dstack/version.py +1 -1
- {dstack-0.19.15.dist-info → dstack-0.19.16.dist-info}/METADATA +1 -1
- {dstack-0.19.15.dist-info → dstack-0.19.16.dist-info}/RECORD +57 -48
- /dstack/_internal/server/services/{prometheus.py → prometheus/custom_metrics.py} +0 -0
- {dstack-0.19.15.dist-info → dstack-0.19.16.dist-info}/WHEEL +0 -0
- {dstack-0.19.15.dist-info → dstack-0.19.16.dist-info}/entry_points.txt +0 -0
- {dstack-0.19.15.dist-info → dstack-0.19.16.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -14,6 +14,7 @@ from dstack._internal.server.schemas.logs import PollLogsRequest
|
|
|
14
14
|
from dstack._internal.server.schemas.runner import LogEvent as RunnerLogEvent
|
|
15
15
|
from dstack._internal.server.services.logs.base import (
|
|
16
16
|
LogStorage,
|
|
17
|
+
LogStorageError,
|
|
17
18
|
b64encode_raw_message,
|
|
18
19
|
unix_time_ms_to_datetime,
|
|
19
20
|
)
|
|
@@ -29,7 +30,9 @@ class FileLogStorage(LogStorage):
|
|
|
29
30
|
self.root = Path(root)
|
|
30
31
|
|
|
31
32
|
def poll_logs(self, project: ProjectModel, request: PollLogsRequest) -> JobSubmissionLogs:
|
|
32
|
-
|
|
33
|
+
if request.descending:
|
|
34
|
+
raise LogStorageError("descending: true is not supported")
|
|
35
|
+
|
|
33
36
|
log_producer = LogProducer.RUNNER if request.diagnose else LogProducer.JOB
|
|
34
37
|
log_file_path = self._get_log_file_path(
|
|
35
38
|
project_name=project.name,
|
|
@@ -37,22 +40,53 @@ class FileLogStorage(LogStorage):
|
|
|
37
40
|
job_submission_id=request.job_submission_id,
|
|
38
41
|
producer=log_producer,
|
|
39
42
|
)
|
|
43
|
+
|
|
44
|
+
start_line = 0
|
|
45
|
+
if request.next_token:
|
|
46
|
+
try:
|
|
47
|
+
start_line = int(request.next_token)
|
|
48
|
+
if start_line < 0:
|
|
49
|
+
raise LogStorageError(
|
|
50
|
+
f"Invalid next_token: {request.next_token}. Must be a non-negative integer."
|
|
51
|
+
)
|
|
52
|
+
except ValueError:
|
|
53
|
+
raise LogStorageError(
|
|
54
|
+
f"Invalid next_token: {request.next_token}. Must be a valid integer."
|
|
55
|
+
)
|
|
56
|
+
|
|
40
57
|
logs = []
|
|
58
|
+
next_token = None
|
|
59
|
+
current_line = 0
|
|
60
|
+
|
|
41
61
|
try:
|
|
42
62
|
with open(log_file_path) as f:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
lines = f.readlines()
|
|
64
|
+
|
|
65
|
+
for i, line in enumerate(lines):
|
|
66
|
+
if current_line < start_line:
|
|
67
|
+
current_line += 1
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
log_event = LogEvent.__response__.parse_raw(line)
|
|
71
|
+
current_line += 1
|
|
72
|
+
|
|
73
|
+
if request.start_time and log_event.timestamp <= request.start_time:
|
|
74
|
+
continue
|
|
75
|
+
if request.end_time is not None and log_event.timestamp >= request.end_time:
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
logs.append(log_event)
|
|
79
|
+
|
|
80
|
+
if len(logs) >= request.limit:
|
|
81
|
+
# Only set next_token if there are more lines to read
|
|
82
|
+
if current_line < len(lines):
|
|
83
|
+
next_token = str(current_line)
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
except IOError as e:
|
|
87
|
+
raise LogStorageError(f"Failed to read log file {log_file_path}: {e}")
|
|
88
|
+
|
|
89
|
+
return JobSubmissionLogs(logs=logs, next_token=next_token)
|
|
56
90
|
|
|
57
91
|
def write_logs(
|
|
58
92
|
self,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
from typing import Iterable, List
|
|
1
|
+
from typing import List
|
|
3
2
|
from uuid import UUID
|
|
4
3
|
|
|
5
4
|
from dstack._internal.core.errors import ServerClientError
|
|
@@ -25,7 +24,8 @@ GCP_LOGGING_AVAILABLE = True
|
|
|
25
24
|
try:
|
|
26
25
|
import google.api_core.exceptions
|
|
27
26
|
import google.auth.exceptions
|
|
28
|
-
from google.cloud import
|
|
27
|
+
from google.cloud import logging_v2
|
|
28
|
+
from google.cloud.logging_v2.types import ListLogEntriesRequest
|
|
29
29
|
except ImportError:
|
|
30
30
|
GCP_LOGGING_AVAILABLE = False
|
|
31
31
|
|
|
@@ -50,7 +50,7 @@ class GCPLogStorage(LogStorage):
|
|
|
50
50
|
|
|
51
51
|
def __init__(self, project_id: str):
|
|
52
52
|
try:
|
|
53
|
-
self.client =
|
|
53
|
+
self.client = logging_v2.Client(project=project_id)
|
|
54
54
|
self.logger = self.client.logger(name=self.LOG_NAME)
|
|
55
55
|
self.logger.list_entries(max_results=1)
|
|
56
56
|
# Python client doesn't seem to support dry_run,
|
|
@@ -64,6 +64,7 @@ class GCPLogStorage(LogStorage):
|
|
|
64
64
|
raise LogStorageError("Insufficient permissions")
|
|
65
65
|
|
|
66
66
|
def poll_logs(self, project: ProjectModel, request: PollLogsRequest) -> JobSubmissionLogs:
|
|
67
|
+
# TODO: GCP may return logs in random order when events have the same timestamp.
|
|
67
68
|
producer = LogProducer.RUNNER if request.diagnose else LogProducer.JOB
|
|
68
69
|
stream_name = self._get_stream_name(
|
|
69
70
|
project_name=project.name,
|
|
@@ -78,23 +79,27 @@ class GCPLogStorage(LogStorage):
|
|
|
78
79
|
log_filters.append(f'timestamp < "{request.end_time.isoformat()}"')
|
|
79
80
|
log_filter = " AND ".join(log_filters)
|
|
80
81
|
|
|
81
|
-
order_by =
|
|
82
|
+
order_by = logging_v2.DESCENDING if request.descending else logging_v2.ASCENDING
|
|
82
83
|
try:
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
# Use low-level API to get access to next_page_token
|
|
85
|
+
request_obj = ListLogEntriesRequest(
|
|
86
|
+
resource_names=[f"projects/{self.client.project}"],
|
|
87
|
+
filter=log_filter,
|
|
85
88
|
order_by=order_by,
|
|
86
|
-
max_results=request.limit,
|
|
87
|
-
# Specify max possible page_size (<=1000) to reduce number of API calls.
|
|
88
89
|
page_size=request.limit,
|
|
90
|
+
page_token=request.next_token,
|
|
89
91
|
)
|
|
92
|
+
response = self.client._logging_api._gapic_api.list_log_entries(request=request_obj)
|
|
93
|
+
|
|
90
94
|
logs = [
|
|
91
95
|
LogEvent(
|
|
92
96
|
timestamp=entry.timestamp,
|
|
93
|
-
message=entry.
|
|
97
|
+
message=entry.json_payload.get("message"),
|
|
94
98
|
log_source=LogEventSource.STDOUT,
|
|
95
99
|
)
|
|
96
|
-
for entry in entries
|
|
100
|
+
for entry in response.entries
|
|
97
101
|
]
|
|
102
|
+
next_token = response.next_page_token or None
|
|
98
103
|
except google.api_core.exceptions.ResourceExhausted as e:
|
|
99
104
|
logger.warning("GCP Logging exception: %s", repr(e))
|
|
100
105
|
# GCP Logging has severely low quota of 60 reads/min for entries.list
|
|
@@ -102,11 +107,7 @@ class GCPLogStorage(LogStorage):
|
|
|
102
107
|
"GCP Logging read request limit exceeded."
|
|
103
108
|
" It's recommended to increase default entries.list request quota from 60 per minute."
|
|
104
109
|
)
|
|
105
|
-
|
|
106
|
-
# This doesn't help with many concurrent clients but
|
|
107
|
-
# should help with one client reading all logs sequentially.
|
|
108
|
-
time.sleep(1)
|
|
109
|
-
return JobSubmissionLogs(logs=logs)
|
|
110
|
+
return JobSubmissionLogs(logs=logs, next_token=next_token if len(logs) > 0 else None)
|
|
110
111
|
|
|
111
112
|
def write_logs(
|
|
112
113
|
self,
|
|
@@ -74,8 +74,8 @@ async def list_user_accessible_projects(
|
|
|
74
74
|
) -> List[Project]:
|
|
75
75
|
"""
|
|
76
76
|
Returns all projects accessible to the user:
|
|
77
|
-
-
|
|
78
|
-
-
|
|
77
|
+
- Projects where user is a member (public or private)
|
|
78
|
+
- Public projects where user is NOT a member
|
|
79
79
|
"""
|
|
80
80
|
if user.global_role == GlobalRole.ADMIN:
|
|
81
81
|
projects = await list_project_models(session=session)
|
|
@@ -150,6 +150,17 @@ async def create_project(
|
|
|
150
150
|
return project_model_to_project(project_model)
|
|
151
151
|
|
|
152
152
|
|
|
153
|
+
async def update_project(
|
|
154
|
+
session: AsyncSession,
|
|
155
|
+
user: UserModel,
|
|
156
|
+
project: ProjectModel,
|
|
157
|
+
is_public: bool,
|
|
158
|
+
):
|
|
159
|
+
"""Update project visibility (public/private)."""
|
|
160
|
+
project.is_public = is_public
|
|
161
|
+
await session.commit()
|
|
162
|
+
|
|
163
|
+
|
|
153
164
|
async def delete_projects(
|
|
154
165
|
session: AsyncSession,
|
|
155
166
|
user: UserModel,
|
|
@@ -163,7 +174,8 @@ async def delete_projects(
|
|
|
163
174
|
for project_name in projects_names:
|
|
164
175
|
if project_name not in user_project_names:
|
|
165
176
|
raise ForbiddenError()
|
|
166
|
-
for
|
|
177
|
+
projects_to_delete = [p for p in user_projects if p.name in projects_names]
|
|
178
|
+
for project in projects_to_delete:
|
|
167
179
|
if not _is_project_admin(user=user, project=project):
|
|
168
180
|
raise ForbiddenError()
|
|
169
181
|
if all(name in projects_names for name in user_project_names):
|
|
@@ -187,7 +199,6 @@ async def set_project_members(
|
|
|
187
199
|
project: ProjectModel,
|
|
188
200
|
members: List[MemberSetting],
|
|
189
201
|
):
|
|
190
|
-
# reload with members
|
|
191
202
|
project = await get_project_model_by_name_or_error(
|
|
192
203
|
session=session,
|
|
193
204
|
project_name=project.name,
|
|
@@ -212,7 +223,6 @@ async def set_project_members(
|
|
|
212
223
|
select(UserModel).where((UserModel.name.in_(names)) | (UserModel.email.in_(names)))
|
|
213
224
|
)
|
|
214
225
|
users = res.scalars().all()
|
|
215
|
-
# Create lookup maps for both username and email
|
|
216
226
|
username_to_user = {user.name: user for user in users}
|
|
217
227
|
email_to_user = {user.email: user for user in users if user.email}
|
|
218
228
|
for i, member in enumerate(members):
|
|
@@ -230,6 +240,77 @@ async def set_project_members(
|
|
|
230
240
|
await session.commit()
|
|
231
241
|
|
|
232
242
|
|
|
243
|
+
async def add_project_members(
|
|
244
|
+
session: AsyncSession,
|
|
245
|
+
user: UserModel,
|
|
246
|
+
project: ProjectModel,
|
|
247
|
+
members: List[MemberSetting],
|
|
248
|
+
):
|
|
249
|
+
"""Add multiple members to a project."""
|
|
250
|
+
project = await get_project_model_by_name_or_error(
|
|
251
|
+
session=session,
|
|
252
|
+
project_name=project.name,
|
|
253
|
+
)
|
|
254
|
+
requesting_user_role = get_user_project_role(user=user, project=project)
|
|
255
|
+
|
|
256
|
+
is_self_join_to_public = (
|
|
257
|
+
len(members) == 1
|
|
258
|
+
and project.is_public
|
|
259
|
+
and (members[0].username == user.name or members[0].username == user.email)
|
|
260
|
+
and requesting_user_role is None
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if not is_self_join_to_public:
|
|
264
|
+
if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
|
|
265
|
+
raise ForbiddenError("Access denied: insufficient permissions to add members")
|
|
266
|
+
|
|
267
|
+
if user.global_role != GlobalRole.ADMIN and requesting_user_role == ProjectRole.MANAGER:
|
|
268
|
+
for member in members:
|
|
269
|
+
if member.project_role == ProjectRole.ADMIN:
|
|
270
|
+
raise ForbiddenError(
|
|
271
|
+
"Access denied: only global admins can add project admins"
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
if members[0].project_role != ProjectRole.USER:
|
|
275
|
+
raise ForbiddenError("Access denied: can only join public projects as user role")
|
|
276
|
+
|
|
277
|
+
usernames = [member.username for member in members]
|
|
278
|
+
|
|
279
|
+
res = await session.execute(
|
|
280
|
+
select(UserModel).where((UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames)))
|
|
281
|
+
)
|
|
282
|
+
users_found = res.scalars().all()
|
|
283
|
+
|
|
284
|
+
username_to_user = {user.name: user for user in users_found}
|
|
285
|
+
email_to_user = {user.email: user for user in users_found if user.email}
|
|
286
|
+
|
|
287
|
+
member_by_user_id = {m.user_id: m for m in project.members}
|
|
288
|
+
|
|
289
|
+
for member_setting in members:
|
|
290
|
+
user_to_add = username_to_user.get(member_setting.username) or email_to_user.get(
|
|
291
|
+
member_setting.username
|
|
292
|
+
)
|
|
293
|
+
if user_to_add is None:
|
|
294
|
+
raise ServerClientError(f"User not found: {member_setting.username}")
|
|
295
|
+
|
|
296
|
+
if user_to_add.id in member_by_user_id:
|
|
297
|
+
existing_member = member_by_user_id[user_to_add.id]
|
|
298
|
+
if existing_member.project_role != member_setting.project_role:
|
|
299
|
+
existing_member.project_role = member_setting.project_role
|
|
300
|
+
else:
|
|
301
|
+
await add_project_member(
|
|
302
|
+
session=session,
|
|
303
|
+
project=project,
|
|
304
|
+
user=user_to_add,
|
|
305
|
+
project_role=member_setting.project_role,
|
|
306
|
+
member_num=None,
|
|
307
|
+
commit=False,
|
|
308
|
+
)
|
|
309
|
+
member_by_user_id[user_to_add.id] = None
|
|
310
|
+
|
|
311
|
+
await session.commit()
|
|
312
|
+
|
|
313
|
+
|
|
233
314
|
async def add_project_member(
|
|
234
315
|
session: AsyncSession,
|
|
235
316
|
project: ProjectModel,
|
|
@@ -497,8 +578,86 @@ def _is_project_admin(
|
|
|
497
578
|
user: UserModel,
|
|
498
579
|
project: ProjectModel,
|
|
499
580
|
) -> bool:
|
|
581
|
+
if user.id == project.owner_id:
|
|
582
|
+
return True
|
|
583
|
+
|
|
500
584
|
for m in project.members:
|
|
501
585
|
if user.id == m.user_id:
|
|
502
586
|
if m.project_role == ProjectRole.ADMIN:
|
|
503
587
|
return True
|
|
504
588
|
return False
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
async def remove_project_members(
|
|
592
|
+
session: AsyncSession,
|
|
593
|
+
user: UserModel,
|
|
594
|
+
project: ProjectModel,
|
|
595
|
+
usernames: List[str],
|
|
596
|
+
):
|
|
597
|
+
"""Remove multiple members from a project."""
|
|
598
|
+
project = await get_project_model_by_name_or_error(
|
|
599
|
+
session=session,
|
|
600
|
+
project_name=project.name,
|
|
601
|
+
)
|
|
602
|
+
requesting_user_role = get_user_project_role(user=user, project=project)
|
|
603
|
+
|
|
604
|
+
is_self_leave = (
|
|
605
|
+
len(usernames) == 1
|
|
606
|
+
and (usernames[0] == user.name or usernames[0] == user.email)
|
|
607
|
+
and requesting_user_role is not None
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
if not is_self_leave:
|
|
611
|
+
if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
|
|
612
|
+
raise ForbiddenError("Access denied: insufficient permissions to remove members")
|
|
613
|
+
|
|
614
|
+
res = await session.execute(
|
|
615
|
+
select(UserModel).where((UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames)))
|
|
616
|
+
)
|
|
617
|
+
users_found = res.scalars().all()
|
|
618
|
+
|
|
619
|
+
username_to_user = {user.name: user for user in users_found}
|
|
620
|
+
email_to_user = {user.email: user for user in users_found if user.email}
|
|
621
|
+
|
|
622
|
+
member_by_user_id = {m.user_id: m for m in project.members}
|
|
623
|
+
|
|
624
|
+
members_to_remove = []
|
|
625
|
+
admin_removals = 0
|
|
626
|
+
|
|
627
|
+
for username in usernames:
|
|
628
|
+
user_to_remove = username_to_user.get(username) or email_to_user.get(username)
|
|
629
|
+
if user_to_remove is None:
|
|
630
|
+
raise ServerClientError(f"User not found: {username}")
|
|
631
|
+
|
|
632
|
+
if user_to_remove.id not in member_by_user_id:
|
|
633
|
+
raise ServerClientError(f"User is not a member of this project: {username}")
|
|
634
|
+
|
|
635
|
+
member_to_remove = member_by_user_id[user_to_remove.id]
|
|
636
|
+
|
|
637
|
+
if member_to_remove.project_role == ProjectRole.ADMIN:
|
|
638
|
+
if is_self_leave:
|
|
639
|
+
total_admins = sum(
|
|
640
|
+
1 for member in project.members if member.project_role == ProjectRole.ADMIN
|
|
641
|
+
)
|
|
642
|
+
if total_admins <= 1:
|
|
643
|
+
raise ServerClientError("Cannot leave project: you are the last admin")
|
|
644
|
+
else:
|
|
645
|
+
if user.global_role != GlobalRole.ADMIN:
|
|
646
|
+
raise ForbiddenError(
|
|
647
|
+
f"Access denied: only global admins can remove project admins (user: {username})"
|
|
648
|
+
)
|
|
649
|
+
admin_removals += 1
|
|
650
|
+
|
|
651
|
+
members_to_remove.append(member_to_remove)
|
|
652
|
+
|
|
653
|
+
if not is_self_leave:
|
|
654
|
+
total_admins = sum(
|
|
655
|
+
1 for member in project.members if member.project_role == ProjectRole.ADMIN
|
|
656
|
+
)
|
|
657
|
+
if admin_removals >= total_admins:
|
|
658
|
+
raise ServerClientError("Cannot remove all project admins")
|
|
659
|
+
|
|
660
|
+
for member in members_to_remove:
|
|
661
|
+
await session.delete(member)
|
|
662
|
+
|
|
663
|
+
await session.commit()
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from prometheus_client import Counter, Histogram
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RunMetrics:
|
|
5
|
+
"""Wrapper class for run-related Prometheus metrics."""
|
|
6
|
+
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self._submit_to_provision_duration = Histogram(
|
|
9
|
+
"dstack_submit_to_provision_duration_seconds",
|
|
10
|
+
"Time from when a run has been submitted and first job provisioning",
|
|
11
|
+
# Buckets optimized for percentile calculation
|
|
12
|
+
buckets=[
|
|
13
|
+
15,
|
|
14
|
+
30,
|
|
15
|
+
45,
|
|
16
|
+
60,
|
|
17
|
+
90,
|
|
18
|
+
120,
|
|
19
|
+
180,
|
|
20
|
+
240,
|
|
21
|
+
300,
|
|
22
|
+
360,
|
|
23
|
+
420,
|
|
24
|
+
480,
|
|
25
|
+
540,
|
|
26
|
+
600,
|
|
27
|
+
900,
|
|
28
|
+
1200,
|
|
29
|
+
1800,
|
|
30
|
+
float("inf"),
|
|
31
|
+
],
|
|
32
|
+
labelnames=["project_name", "run_type"],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
self._pending_runs_total = Counter(
|
|
36
|
+
"dstack_pending_runs_total",
|
|
37
|
+
"Number of pending runs",
|
|
38
|
+
labelnames=["project_name", "run_type"],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def log_submit_to_provision_duration(
|
|
42
|
+
self, duration_seconds: float, project_name: str, run_type: str
|
|
43
|
+
):
|
|
44
|
+
self._submit_to_provision_duration.labels(
|
|
45
|
+
project_name=project_name, run_type=run_type
|
|
46
|
+
).observe(duration_seconds)
|
|
47
|
+
|
|
48
|
+
def increment_pending_runs(self, project_name: str, run_type: str):
|
|
49
|
+
self._pending_runs_total.labels(project_name=project_name, run_type=run_type).inc()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
run_metrics = RunMetrics()
|
|
@@ -589,7 +589,7 @@ async def stop_run(session: AsyncSession, run_model: RunModel, abort: bool):
|
|
|
589
589
|
select(RunModel)
|
|
590
590
|
.where(RunModel.id == run_model.id)
|
|
591
591
|
.order_by(RunModel.id) # take locks in order
|
|
592
|
-
.with_for_update()
|
|
592
|
+
.with_for_update(key_share=True)
|
|
593
593
|
.execution_options(populate_existing=True)
|
|
594
594
|
)
|
|
595
595
|
run_model = res.scalar_one()
|
|
@@ -597,7 +597,7 @@ async def stop_run(session: AsyncSession, run_model: RunModel, abort: bool):
|
|
|
597
597
|
select(JobModel)
|
|
598
598
|
.where(JobModel.run_id == run_model.id)
|
|
599
599
|
.order_by(JobModel.id) # take locks in order
|
|
600
|
-
.with_for_update()
|
|
600
|
+
.with_for_update(key_share=True)
|
|
601
601
|
.execution_options(populate_existing=True)
|
|
602
602
|
)
|
|
603
603
|
if run_model.status.is_finished():
|
|
@@ -633,7 +633,7 @@ async def delete_runs(
|
|
|
633
633
|
select(RunModel)
|
|
634
634
|
.where(RunModel.id.in_(run_ids))
|
|
635
635
|
.order_by(RunModel.id) # take locks in order
|
|
636
|
-
.with_for_update()
|
|
636
|
+
.with_for_update(key_share=True)
|
|
637
637
|
)
|
|
638
638
|
run_models = res.scalars().all()
|
|
639
639
|
active_runs = [r for r in run_models if not r.status.is_finished()]
|
|
@@ -3,6 +3,7 @@ Application logic related to `type: service` runs.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import uuid
|
|
6
|
+
from datetime import datetime
|
|
6
7
|
from typing import Optional
|
|
7
8
|
from urllib.parse import urlparse
|
|
8
9
|
|
|
@@ -265,7 +266,7 @@ async def update_service_desired_replica_count(
|
|
|
265
266
|
session: AsyncSession,
|
|
266
267
|
run_model: RunModel,
|
|
267
268
|
configuration: ServiceConfiguration,
|
|
268
|
-
last_scaled_at: Optional[
|
|
269
|
+
last_scaled_at: Optional[datetime],
|
|
269
270
|
) -> None:
|
|
270
271
|
scaler = get_service_scaler(configuration)
|
|
271
272
|
stats = None
|
|
@@ -44,9 +44,7 @@ async def list_users_for_user(
|
|
|
44
44
|
session: AsyncSession,
|
|
45
45
|
user: UserModel,
|
|
46
46
|
) -> List[User]:
|
|
47
|
-
|
|
48
|
-
return await list_all_users(session=session)
|
|
49
|
-
return [user_model_to_user(user)]
|
|
47
|
+
return await list_all_users(session=session)
|
|
50
48
|
|
|
51
49
|
|
|
52
50
|
async def list_all_users(
|
|
@@ -275,7 +275,7 @@ async def delete_volumes(session: AsyncSession, project: ProjectModel, names: Li
|
|
|
275
275
|
.options(selectinload(VolumeModel.attachments))
|
|
276
276
|
.execution_options(populate_existing=True)
|
|
277
277
|
.order_by(VolumeModel.id) # take locks in order
|
|
278
|
-
.with_for_update()
|
|
278
|
+
.with_for_update(key_share=True)
|
|
279
279
|
)
|
|
280
280
|
volume_models = res.scalars().unique().all()
|
|
281
281
|
for volume_model in volume_models:
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><title>dstack</title><meta name="description" content="Get GPUs at the best prices and availability from a wide range of providers. No cloud account of your own is required.
|
|
2
2
|
"/><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet"><meta name="og:title" content="dstack"><meta name="og:type" content="article"><meta name="og:image" content="/splash_thumbnail.png"><meta name="og:description" content="Get GPUs at the best prices and availability from a wide range of providers. No cloud account of your own is required.
|
|
3
|
-
"><link rel="icon" type="image/x-icon" href="/assets/favicon.ico"><link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"><link rel="icon" type="image/png" sizes="48x48" href="/assets/favicon-48x48.png"><link rel="manifest" href="/assets/manifest.webmanifest"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#fff"><meta name="application-name" content="dstackai"><link rel="apple-touch-icon" sizes="57x57" href="/assets/apple-touch-icon-57x57.png"><link rel="apple-touch-icon" sizes="60x60" href="/assets/apple-touch-icon-60x60.png"><link rel="apple-touch-icon" sizes="72x72" href="/assets/apple-touch-icon-72x72.png"><link rel="apple-touch-icon" sizes="76x76" href="/assets/apple-touch-icon-76x76.png"><link rel="apple-touch-icon" sizes="114x114" href="/assets/apple-touch-icon-114x114.png"><link rel="apple-touch-icon" sizes="120x120" href="/assets/apple-touch-icon-120x120.png"><link rel="apple-touch-icon" sizes="144x144" href="/assets/apple-touch-icon-144x144.png"><link rel="apple-touch-icon" sizes="152x152" href="/assets/apple-touch-icon-152x152.png"><link rel="apple-touch-icon" sizes="167x167" href="/assets/apple-touch-icon-167x167.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-180x180.png"><link rel="apple-touch-icon" sizes="1024x1024" href="/assets/apple-touch-icon-1024x1024.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="dstackai"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-640x1136.png"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1136x640.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-750x1334.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1334x750.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1125x2436.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2436x1125.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1170x2532.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2532x1170.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1179x2556.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2556x1179.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-828x1792.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1792x828.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2688.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2688x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2208.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2208x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1284x2778.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2778x1284.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1290x2796.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2796x1290.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1488x2266.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2266x1488.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1536x2048.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2048x1536.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1620x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1620.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1640x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1640.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2388.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2388x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2224.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2224x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-2048x2732.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2732x2048.png"><meta name="msapplication-TileColor" content="#fff"><meta name="msapplication-TileImage" content="/assets/mstile-144x144.png"><meta name="msapplication-config" content="/assets/browserconfig.xml"><link rel="yandex-tableau-widget" href="/assets/yandex-browser-manifest.json"><script defer="defer" src="/main-
|
|
3
|
+
"><link rel="icon" type="image/x-icon" href="/assets/favicon.ico"><link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"><link rel="icon" type="image/png" sizes="48x48" href="/assets/favicon-48x48.png"><link rel="manifest" href="/assets/manifest.webmanifest"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#fff"><meta name="application-name" content="dstackai"><link rel="apple-touch-icon" sizes="57x57" href="/assets/apple-touch-icon-57x57.png"><link rel="apple-touch-icon" sizes="60x60" href="/assets/apple-touch-icon-60x60.png"><link rel="apple-touch-icon" sizes="72x72" href="/assets/apple-touch-icon-72x72.png"><link rel="apple-touch-icon" sizes="76x76" href="/assets/apple-touch-icon-76x76.png"><link rel="apple-touch-icon" sizes="114x114" href="/assets/apple-touch-icon-114x114.png"><link rel="apple-touch-icon" sizes="120x120" href="/assets/apple-touch-icon-120x120.png"><link rel="apple-touch-icon" sizes="144x144" href="/assets/apple-touch-icon-144x144.png"><link rel="apple-touch-icon" sizes="152x152" href="/assets/apple-touch-icon-152x152.png"><link rel="apple-touch-icon" sizes="167x167" href="/assets/apple-touch-icon-167x167.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-180x180.png"><link rel="apple-touch-icon" sizes="1024x1024" href="/assets/apple-touch-icon-1024x1024.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="dstackai"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-640x1136.png"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1136x640.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-750x1334.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1334x750.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1125x2436.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2436x1125.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1170x2532.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2532x1170.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1179x2556.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2556x1179.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-828x1792.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1792x828.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2688.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2688x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2208.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2208x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1284x2778.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2778x1284.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1290x2796.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2796x1290.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1488x2266.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2266x1488.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1536x2048.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2048x1536.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1620x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1620.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1640x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1640.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2388.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2388x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2224.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2224x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-2048x2732.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2732x2048.png"><meta name="msapplication-TileColor" content="#fff"><meta name="msapplication-TileImage" content="/assets/mstile-144x144.png"><meta name="msapplication-config" content="/assets/browserconfig.xml"><link rel="yandex-tableau-widget" href="/assets/yandex-browser-manifest.json"><script defer="defer" src="/main-a4eafa74304e587d037c.js"></script><link href="/main-f53d6d0d42f8d61df1de.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div class="b-page-header" id="header"></div><div id="root"></div></body></html>
|