aind-data-transfer-service 1.17.0__py3-none-any.whl → 1.17.2__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.
- aind_data_transfer_service/__init__.py +1 -1
- aind_data_transfer_service/hpc/client.py +28 -25
- aind_data_transfer_service/server.py +68 -57
- {aind_data_transfer_service-1.17.0.dist-info → aind_data_transfer_service-1.17.2.dist-info}/METADATA +1 -1
- aind_data_transfer_service-1.17.2.dist-info/RECORD +18 -0
- aind_data_transfer_service/templates/admin.html +0 -45
- aind_data_transfer_service/templates/index.html +0 -258
- aind_data_transfer_service/templates/job_params.html +0 -405
- aind_data_transfer_service/templates/job_status.html +0 -324
- aind_data_transfer_service/templates/job_tasks_table.html +0 -146
- aind_data_transfer_service/templates/task_logs.html +0 -31
- aind_data_transfer_service-1.17.0.dist-info/RECORD +0 -24
- {aind_data_transfer_service-1.17.0.dist-info → aind_data_transfer_service-1.17.2.dist-info}/WHEEL +0 -0
- {aind_data_transfer_service-1.17.0.dist-info → aind_data_transfer_service-1.17.2.dist-info}/licenses/LICENSE +0 -0
- {aind_data_transfer_service-1.17.0.dist-info → aind_data_transfer_service-1.17.2.dist-info}/top_level.txt +0 -0
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
import json
|
|
4
4
|
from typing import List, Optional, Union
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
from httpx import AsyncClient, Response
|
|
7
7
|
from pydantic import Field, SecretStr, field_validator
|
|
8
8
|
from pydantic_settings import BaseSettings
|
|
9
|
-
from requests.models import Response
|
|
10
9
|
|
|
11
10
|
from aind_data_transfer_service.hpc.models import HpcJobSubmitSettings
|
|
12
11
|
|
|
@@ -75,37 +74,41 @@ class HpcClient:
|
|
|
75
74
|
"X-SLURM-USER-TOKEN": self.configs.hpc_token.get_secret_value(),
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
def get_node_status(self) -> Response:
|
|
77
|
+
async def get_node_status(self) -> Response:
|
|
79
78
|
"""Get status of nodes"""
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
async with AsyncClient() as async_client:
|
|
80
|
+
response = await async_client.get(
|
|
81
|
+
url=self._node_status_url, headers=self.__headers
|
|
82
|
+
)
|
|
83
83
|
return response
|
|
84
84
|
|
|
85
|
-
def get_job_status(self, job_id: Union[str, int]) -> Response:
|
|
85
|
+
async def get_job_status(self, job_id: Union[str, int]) -> Response:
|
|
86
86
|
"""Get status of job"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
async with AsyncClient() as async_client:
|
|
88
|
+
response = await async_client.get(
|
|
89
|
+
url=self._job_status_url + "/" + str(job_id),
|
|
90
|
+
headers=self.__headers,
|
|
91
|
+
)
|
|
91
92
|
return response
|
|
92
93
|
|
|
93
|
-
def get_jobs(self) -> Response:
|
|
94
|
+
async def get_jobs(self) -> Response:
|
|
94
95
|
"""Get status of job"""
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
async with AsyncClient() as async_client:
|
|
97
|
+
response = await async_client.get(
|
|
98
|
+
url=self._jobs_url,
|
|
99
|
+
headers=self.__headers,
|
|
100
|
+
)
|
|
99
101
|
return response
|
|
100
102
|
|
|
101
|
-
def submit_job(self, job_def: dict) -> Response:
|
|
103
|
+
async def submit_job(self, job_def: dict) -> Response:
|
|
102
104
|
"""Submit a job defined by job def"""
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
async with AsyncClient() as async_client:
|
|
106
|
+
response = await async_client.post(
|
|
107
|
+
url=self._job_submit_url, json=job_def, headers=self.__headers
|
|
108
|
+
)
|
|
106
109
|
return response
|
|
107
110
|
|
|
108
|
-
def submit_hpc_job(
|
|
111
|
+
async def submit_hpc_job(
|
|
109
112
|
self,
|
|
110
113
|
script: str,
|
|
111
114
|
job: Optional[HpcJobSubmitSettings] = None,
|
|
@@ -144,8 +147,8 @@ class HpcClient:
|
|
|
144
147
|
],
|
|
145
148
|
"script": script,
|
|
146
149
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
async with AsyncClient() as async_client:
|
|
151
|
+
response = await async_client.post(
|
|
152
|
+
url=self._job_submit_url, json=job_def, headers=self.__headers
|
|
153
|
+
)
|
|
151
154
|
return response
|
|
@@ -10,7 +10,6 @@ from pathlib import PurePosixPath
|
|
|
10
10
|
from typing import Any, List, Optional, Union
|
|
11
11
|
|
|
12
12
|
import boto3
|
|
13
|
-
import requests
|
|
14
13
|
from aind_data_transfer_models import (
|
|
15
14
|
__version__ as aind_data_transfer_models_version,
|
|
16
15
|
)
|
|
@@ -96,12 +95,13 @@ logger = get_logger(log_configs=LoggingConfigs())
|
|
|
96
95
|
project_names_url = os.getenv("AIND_METADATA_SERVICE_PROJECT_NAMES_URL")
|
|
97
96
|
|
|
98
97
|
|
|
99
|
-
def get_project_names() -> List[str]:
|
|
98
|
+
async def get_project_names() -> List[str]:
|
|
100
99
|
"""Get a list of project_names"""
|
|
101
100
|
# TODO: Cache response for 5 minutes
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
async with AsyncClient() as async_client:
|
|
102
|
+
response = await async_client.get(project_names_url)
|
|
103
|
+
response.raise_for_status()
|
|
104
|
+
project_names = response.json()["data"]
|
|
105
105
|
return project_names
|
|
106
106
|
|
|
107
107
|
|
|
@@ -281,7 +281,7 @@ async def validate_csv(request: Request):
|
|
|
281
281
|
)
|
|
282
282
|
context = {
|
|
283
283
|
"job_types": get_job_types("v2"),
|
|
284
|
-
"project_names": get_project_names(),
|
|
284
|
+
"project_names": await get_project_names(),
|
|
285
285
|
"current_jobs": current_jobs,
|
|
286
286
|
}
|
|
287
287
|
for row in csv_reader:
|
|
@@ -375,7 +375,7 @@ async def validate_json_v2(request: Request):
|
|
|
375
375
|
_, current_jobs = await get_airflow_jobs(params=params, get_confs=True)
|
|
376
376
|
context = {
|
|
377
377
|
"job_types": get_job_types("v2"),
|
|
378
|
-
"project_names": get_project_names(),
|
|
378
|
+
"project_names": await get_project_names(),
|
|
379
379
|
"current_jobs": current_jobs,
|
|
380
380
|
}
|
|
381
381
|
with validation_context_v2(context):
|
|
@@ -431,7 +431,7 @@ async def validate_json(request: Request):
|
|
|
431
431
|
logger.info("Received request to validate json")
|
|
432
432
|
content = await request.json()
|
|
433
433
|
try:
|
|
434
|
-
project_names = get_project_names()
|
|
434
|
+
project_names = await get_project_names()
|
|
435
435
|
with validation_context({"project_names": project_names}):
|
|
436
436
|
validated_model = SubmitJobRequest.model_validate_json(
|
|
437
437
|
json.dumps(content)
|
|
@@ -491,7 +491,7 @@ async def submit_jobs_v2(request: Request):
|
|
|
491
491
|
_, current_jobs = await get_airflow_jobs(params=params, get_confs=True)
|
|
492
492
|
context = {
|
|
493
493
|
"job_types": get_job_types("v2"),
|
|
494
|
-
"project_names": get_project_names(),
|
|
494
|
+
"project_names": await get_project_names(),
|
|
495
495
|
"current_jobs": current_jobs,
|
|
496
496
|
}
|
|
497
497
|
with validation_context_v2(context):
|
|
@@ -499,7 +499,6 @@ async def submit_jobs_v2(request: Request):
|
|
|
499
499
|
full_content = json.loads(
|
|
500
500
|
model.model_dump_json(warnings=False, exclude_none=True)
|
|
501
501
|
)
|
|
502
|
-
# TODO: Replace with httpx async client
|
|
503
502
|
logger.info(
|
|
504
503
|
f"Valid request detected. Sending list of jobs. "
|
|
505
504
|
f"dag_id: {model.dag_id}"
|
|
@@ -511,19 +510,23 @@ async def submit_jobs_v2(request: Request):
|
|
|
511
510
|
f"{job_index} of {total_jobs}."
|
|
512
511
|
)
|
|
513
512
|
|
|
514
|
-
|
|
515
|
-
url=os.getenv("AIND_AIRFLOW_SERVICE_URL"),
|
|
513
|
+
async with AsyncClient(
|
|
516
514
|
auth=(
|
|
517
515
|
os.getenv("AIND_AIRFLOW_SERVICE_USER"),
|
|
518
516
|
os.getenv("AIND_AIRFLOW_SERVICE_PASSWORD"),
|
|
519
|
-
)
|
|
520
|
-
|
|
521
|
-
|
|
517
|
+
)
|
|
518
|
+
) as async_client:
|
|
519
|
+
response = await async_client.post(
|
|
520
|
+
url=os.getenv("AIND_AIRFLOW_SERVICE_URL"),
|
|
521
|
+
json={"conf": full_content},
|
|
522
|
+
)
|
|
523
|
+
status_code = response.status_code
|
|
524
|
+
response_json = response.json()
|
|
522
525
|
return JSONResponse(
|
|
523
|
-
status_code=
|
|
526
|
+
status_code=status_code,
|
|
524
527
|
content={
|
|
525
528
|
"message": "Submitted request to airflow",
|
|
526
|
-
"data": {"responses": [
|
|
529
|
+
"data": {"responses": [response_json], "errors": []},
|
|
527
530
|
},
|
|
528
531
|
)
|
|
529
532
|
except ValidationError as e:
|
|
@@ -551,13 +554,12 @@ async def submit_jobs(request: Request):
|
|
|
551
554
|
logger.info("Received request to submit jobs")
|
|
552
555
|
content = await request.json()
|
|
553
556
|
try:
|
|
554
|
-
project_names = get_project_names()
|
|
557
|
+
project_names = await get_project_names()
|
|
555
558
|
with validation_context({"project_names": project_names}):
|
|
556
559
|
model = SubmitJobRequest.model_validate_json(json.dumps(content))
|
|
557
560
|
full_content = json.loads(
|
|
558
561
|
model.model_dump_json(warnings=False, exclude_none=True)
|
|
559
562
|
)
|
|
560
|
-
# TODO: Replace with httpx async client
|
|
561
563
|
logger.info(
|
|
562
564
|
f"Valid request detected. Sending list of jobs. "
|
|
563
565
|
f"Job Type: {model.job_type}"
|
|
@@ -569,19 +571,23 @@ async def submit_jobs(request: Request):
|
|
|
569
571
|
f"{job_index} of {total_jobs}."
|
|
570
572
|
)
|
|
571
573
|
|
|
572
|
-
|
|
573
|
-
url=os.getenv("AIND_AIRFLOW_SERVICE_URL"),
|
|
574
|
+
async with AsyncClient(
|
|
574
575
|
auth=(
|
|
575
576
|
os.getenv("AIND_AIRFLOW_SERVICE_USER"),
|
|
576
577
|
os.getenv("AIND_AIRFLOW_SERVICE_PASSWORD"),
|
|
577
|
-
)
|
|
578
|
-
|
|
579
|
-
|
|
578
|
+
)
|
|
579
|
+
) as async_client:
|
|
580
|
+
response = await async_client.post(
|
|
581
|
+
url=os.getenv("AIND_AIRFLOW_SERVICE_URL"),
|
|
582
|
+
json={"conf": full_content},
|
|
583
|
+
)
|
|
584
|
+
status_code = response.status_code
|
|
585
|
+
response_json = response.json()
|
|
580
586
|
return JSONResponse(
|
|
581
|
-
status_code=
|
|
587
|
+
status_code=status_code,
|
|
582
588
|
content={
|
|
583
589
|
"message": "Submitted request to airflow",
|
|
584
|
-
"data": {"responses": [
|
|
590
|
+
"data": {"responses": [response_json], "errors": []},
|
|
585
591
|
},
|
|
586
592
|
)
|
|
587
593
|
|
|
@@ -645,7 +651,7 @@ async def submit_basic_jobs(request: Request):
|
|
|
645
651
|
for hpc_job in hpc_jobs:
|
|
646
652
|
try:
|
|
647
653
|
job_def = hpc_job.job_definition
|
|
648
|
-
response = hpc_client.submit_job(job_def)
|
|
654
|
+
response = await hpc_client.submit_job(job_def)
|
|
649
655
|
response_json = response.json()
|
|
650
656
|
responses.append(response_json)
|
|
651
657
|
# Add pause to stagger job requests to the hpc
|
|
@@ -769,7 +775,7 @@ async def submit_hpc_jobs(request: Request): # noqa: C901
|
|
|
769
775
|
hpc_job_def = hpc_job[0]
|
|
770
776
|
try:
|
|
771
777
|
script = hpc_job[1]
|
|
772
|
-
response = hpc_client.submit_hpc_job(
|
|
778
|
+
response = await hpc_client.submit_hpc_job(
|
|
773
779
|
job=hpc_job_def, script=script
|
|
774
780
|
)
|
|
775
781
|
response_json = response.json()
|
|
@@ -842,20 +848,23 @@ async def get_tasks_list(request: Request):
|
|
|
842
848
|
request.query_params
|
|
843
849
|
)
|
|
844
850
|
params_dict = json.loads(params.model_dump_json())
|
|
845
|
-
|
|
846
|
-
url=(
|
|
847
|
-
f"{url}/{params.dag_id}/dagRuns/{params.dag_run_id}/"
|
|
848
|
-
"taskInstances"
|
|
849
|
-
),
|
|
851
|
+
async with AsyncClient(
|
|
850
852
|
auth=(
|
|
851
853
|
os.getenv("AIND_AIRFLOW_SERVICE_USER"),
|
|
852
854
|
os.getenv("AIND_AIRFLOW_SERVICE_PASSWORD"),
|
|
853
|
-
)
|
|
854
|
-
)
|
|
855
|
-
|
|
856
|
-
|
|
855
|
+
)
|
|
856
|
+
) as async_client:
|
|
857
|
+
response_tasks = await async_client.get(
|
|
858
|
+
url=(
|
|
859
|
+
f"{url}/{params.dag_id}/dagRuns/{params.dag_run_id}/"
|
|
860
|
+
"taskInstances"
|
|
861
|
+
),
|
|
862
|
+
)
|
|
863
|
+
status_code = response_tasks.status_code
|
|
864
|
+
response_json = response_tasks.json()
|
|
865
|
+
if status_code == 200:
|
|
857
866
|
task_instances = AirflowTaskInstancesResponse.model_validate_json(
|
|
858
|
-
json.dumps(
|
|
867
|
+
json.dumps(response_json)
|
|
859
868
|
)
|
|
860
869
|
job_tasks_list = sorted(
|
|
861
870
|
[
|
|
@@ -876,7 +885,7 @@ async def get_tasks_list(request: Request):
|
|
|
876
885
|
message = "Error retrieving job tasks list from airflow"
|
|
877
886
|
data = {
|
|
878
887
|
"params": params_dict,
|
|
879
|
-
"errors": [
|
|
888
|
+
"errors": [response_json],
|
|
880
889
|
}
|
|
881
890
|
except ValidationError as e:
|
|
882
891
|
logger.warning(f"There was a validation error process task_list: {e}")
|
|
@@ -906,27 +915,29 @@ async def get_task_logs(request: Request):
|
|
|
906
915
|
)
|
|
907
916
|
params_dict = json.loads(params.model_dump_json())
|
|
908
917
|
params_full = dict(params)
|
|
909
|
-
|
|
910
|
-
url=(
|
|
911
|
-
f"{url}/{params.dag_id}/dagRuns/{params.dag_run_id}"
|
|
912
|
-
f"/taskInstances/{params.task_id}/logs/{params.try_number}"
|
|
913
|
-
),
|
|
918
|
+
async with AsyncClient(
|
|
914
919
|
auth=(
|
|
915
920
|
os.getenv("AIND_AIRFLOW_SERVICE_USER"),
|
|
916
921
|
os.getenv("AIND_AIRFLOW_SERVICE_PASSWORD"),
|
|
917
|
-
)
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
"
|
|
929
|
-
|
|
922
|
+
)
|
|
923
|
+
) as async_client:
|
|
924
|
+
response_logs = await async_client.get(
|
|
925
|
+
url=(
|
|
926
|
+
f"{url}/{params.dag_id}/dagRuns/{params.dag_run_id}"
|
|
927
|
+
f"/taskInstances/{params.task_id}/logs/{params.try_number}"
|
|
928
|
+
),
|
|
929
|
+
params=params_dict,
|
|
930
|
+
)
|
|
931
|
+
status_code = response_logs.status_code
|
|
932
|
+
if status_code == 200:
|
|
933
|
+
message = "Retrieved task logs from airflow"
|
|
934
|
+
data = {"params": params_full, "logs": response_logs.text}
|
|
935
|
+
else:
|
|
936
|
+
message = "Error retrieving task logs from airflow"
|
|
937
|
+
data = {
|
|
938
|
+
"params": params_full,
|
|
939
|
+
"errors": [response_logs.json()],
|
|
940
|
+
}
|
|
930
941
|
except ValidationError as e:
|
|
931
942
|
logger.warning(f"Error validating request parameters: {e}")
|
|
932
943
|
status_code = 406
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
aind_data_transfer_service/__init__.py,sha256=cCMYl-lxHhXlEMAHdJ7INgHPd0O6ETEp9wsqf6DT8TQ,272
|
|
2
|
+
aind_data_transfer_service/log_handler.py,sha256=c7a-gLmZeRpeCUBwCz6XsTszWXQeQdR7eKZtas4llXM,1700
|
|
3
|
+
aind_data_transfer_service/server.py,sha256=CQdOkX86qn8RQm36NqS5b0aUyNCjvaEbTIObaza6RY0,48910
|
|
4
|
+
aind_data_transfer_service/configs/__init__.py,sha256=9W5GTuso9Is1B9X16RXcdb_GxasZvj6qDzOBDv0AbTc,36
|
|
5
|
+
aind_data_transfer_service/configs/csv_handler.py,sha256=hCdfAYZW_49-l1rbua5On2Tw2ks674Z-MgB_NJlIkU4,5746
|
|
6
|
+
aind_data_transfer_service/configs/job_configs.py,sha256=T-h5N6lyY9xTZ_xg_5FxkyYuMdagApbE6xalxFQ-bqA,18848
|
|
7
|
+
aind_data_transfer_service/configs/job_upload_template.py,sha256=aC5m1uD_YcpbggFQ-yZ7ZJSUUGX1yQqQLF3SwmljrLk,5127
|
|
8
|
+
aind_data_transfer_service/hpc/__init__.py,sha256=YNc68YNlmXwKIPFMIViz_K4XzVVHkLPEBOFyO5DKMKI,53
|
|
9
|
+
aind_data_transfer_service/hpc/client.py,sha256=-WwNjpV2FAJMiXT3Y_JCllSbXPyvqrkPxpNWE0BAWzY,5084
|
|
10
|
+
aind_data_transfer_service/hpc/models.py,sha256=-7HhV16s_MUyKPy0x0FGIbnq8DPL2qJAzJO5G7003AE,16184
|
|
11
|
+
aind_data_transfer_service/models/__init__.py,sha256=Meym73bEZ9nQr4QoeyhQmV3nRTYtd_4kWKPNygsBfJg,25
|
|
12
|
+
aind_data_transfer_service/models/core.py,sha256=uXtPUqjxKalg-sE8MxaJr11w_T_KKBRBSJuUgwoMZlQ,10135
|
|
13
|
+
aind_data_transfer_service/models/internal.py,sha256=tWO1yRMu9hHLv0mt7QhOwTPWGbKApWmCf1wwajx5qjI,10681
|
|
14
|
+
aind_data_transfer_service-1.17.2.dist-info/licenses/LICENSE,sha256=U0Y7B3gZJHXpjJVLgTQjM8e_c8w4JJpLgGhIdsoFR1Y,1092
|
|
15
|
+
aind_data_transfer_service-1.17.2.dist-info/METADATA,sha256=DotVNzRhHX0woD5qMefPipnYAja4EbXje8luub37g9I,2478
|
|
16
|
+
aind_data_transfer_service-1.17.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
+
aind_data_transfer_service-1.17.2.dist-info/top_level.txt,sha256=XmxH0q27Jholj2-VYh-6WMrh9Lw6kkuCX_fdsj3SaFE,27
|
|
18
|
+
aind_data_transfer_service-1.17.2.dist-info/RECORD,,
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html>
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
6
|
-
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
7
|
-
<title>{% block title %} {% endblock %} AIND Data Transfer Service Admin</title>
|
|
8
|
-
<style>
|
|
9
|
-
body {
|
|
10
|
-
margin: 20px;
|
|
11
|
-
font-family: arial, sans-serif;
|
|
12
|
-
}
|
|
13
|
-
nav {
|
|
14
|
-
height: 40px;
|
|
15
|
-
}
|
|
16
|
-
</style>
|
|
17
|
-
</head>
|
|
18
|
-
<body>
|
|
19
|
-
<nav>
|
|
20
|
-
<a href="/">Submit Jobs</a> |
|
|
21
|
-
<a href="/jobs">Job Status</a> |
|
|
22
|
-
<a href="/job_params">Job Parameters</a> |
|
|
23
|
-
<a title="Download job template as .xslx" href="/api/job_upload_template" download>Job Submit Template</a> |
|
|
24
|
-
<a title="List of project names" href="{{ project_names_url }}" target="_blank">Project Names</a> |
|
|
25
|
-
<a title="For more information click here" href="https://aind-data-transfer-service.readthedocs.io"
|
|
26
|
-
target="_blank">Help</a> |
|
|
27
|
-
<a href="/admin">Admin</a>
|
|
28
|
-
<a href="/logout" class="float-end">Log out</a>
|
|
29
|
-
</nav>
|
|
30
|
-
<div>
|
|
31
|
-
<h3>Admin</h3>
|
|
32
|
-
<div>Hello {{user_name}}, welcome to the admin page</div>
|
|
33
|
-
<div>Email: {{user_email}}</div>
|
|
34
|
-
<h4 class="mt-4">Job Type Parameters</h4>
|
|
35
|
-
<div class="mb-2">
|
|
36
|
-
<div class="alert alert-info mb-0 p-4" role="alert">
|
|
37
|
-
To manage job type parameters, go to the <a href="/job_params" class="alert-link">Job Parameters</a> page.
|
|
38
|
-
Select an existing parameter, or click the
|
|
39
|
-
<button type="button" class="btn btn-success btn-sm" disabled><i class="bi bi-plus-circle"></i> Add New Parameter</button>
|
|
40
|
-
button.
|
|
41
|
-
</div>
|
|
42
|
-
</div>
|
|
43
|
-
</div>
|
|
44
|
-
</body>
|
|
45
|
-
</html>
|