ivoryos 1.2.5__py3-none-any.whl → 1.4.4__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.
- docs/source/conf.py +84 -0
- ivoryos/__init__.py +16 -246
- ivoryos/app.py +154 -0
- ivoryos/optimizer/ax_optimizer.py +55 -28
- ivoryos/optimizer/base_optimizer.py +20 -1
- ivoryos/optimizer/baybe_optimizer.py +27 -17
- ivoryos/optimizer/nimo_optimizer.py +173 -0
- ivoryos/optimizer/registry.py +3 -1
- ivoryos/routes/auth/auth.py +35 -8
- ivoryos/routes/auth/templates/change_password.html +32 -0
- ivoryos/routes/control/control.py +58 -28
- ivoryos/routes/control/control_file.py +12 -15
- ivoryos/routes/control/control_new_device.py +21 -11
- ivoryos/routes/control/templates/controllers.html +27 -0
- ivoryos/routes/control/utils.py +2 -0
- ivoryos/routes/data/data.py +110 -44
- ivoryos/routes/data/templates/components/step_card.html +78 -13
- ivoryos/routes/data/templates/workflow_view.html +343 -113
- ivoryos/routes/design/design.py +59 -10
- ivoryos/routes/design/design_file.py +3 -3
- ivoryos/routes/design/design_step.py +43 -17
- ivoryos/routes/design/templates/components/action_form.html +2 -2
- ivoryos/routes/design/templates/components/canvas_main.html +6 -1
- ivoryos/routes/design/templates/components/edit_action_form.html +18 -3
- ivoryos/routes/design/templates/components/info_modal.html +318 -0
- ivoryos/routes/design/templates/components/instruments_panel.html +23 -1
- ivoryos/routes/design/templates/components/python_code_overlay.html +27 -10
- ivoryos/routes/design/templates/experiment_builder.html +3 -0
- ivoryos/routes/execute/execute.py +82 -22
- ivoryos/routes/execute/templates/components/logging_panel.html +50 -25
- ivoryos/routes/execute/templates/components/run_tabs.html +45 -2
- ivoryos/routes/execute/templates/components/tab_bayesian.html +447 -325
- ivoryos/routes/execute/templates/components/tab_configuration.html +303 -18
- ivoryos/routes/execute/templates/components/tab_repeat.html +6 -2
- ivoryos/routes/execute/templates/experiment_run.html +0 -264
- ivoryos/routes/library/library.py +9 -11
- ivoryos/routes/main/main.py +30 -2
- ivoryos/server.py +180 -0
- ivoryos/socket_handlers.py +1 -1
- ivoryos/static/ivoryos_logo.png +0 -0
- ivoryos/static/js/action_handlers.js +259 -88
- ivoryos/static/js/socket_handler.js +40 -5
- ivoryos/static/js/sortable_design.js +29 -11
- ivoryos/templates/base.html +61 -2
- ivoryos/utils/bo_campaign.py +18 -17
- ivoryos/utils/client_proxy.py +267 -36
- ivoryos/utils/db_models.py +286 -60
- ivoryos/utils/decorators.py +34 -0
- ivoryos/utils/form.py +52 -19
- ivoryos/utils/global_config.py +21 -0
- ivoryos/utils/nest_script.py +314 -0
- ivoryos/utils/py_to_json.py +80 -10
- ivoryos/utils/script_runner.py +573 -189
- ivoryos/utils/task_runner.py +69 -22
- ivoryos/utils/utils.py +48 -5
- ivoryos/version.py +1 -1
- {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/METADATA +109 -47
- ivoryos-1.4.4.dist-info/RECORD +119 -0
- ivoryos-1.4.4.dist-info/top_level.txt +3 -0
- tests/__init__.py +0 -0
- tests/conftest.py +133 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_route_auth.py +80 -0
- tests/integration/test_route_control.py +94 -0
- tests/integration/test_route_database.py +61 -0
- tests/integration/test_route_design.py +36 -0
- tests/integration/test_route_main.py +35 -0
- tests/integration/test_sockets.py +26 -0
- tests/unit/test_type_conversion.py +42 -0
- tests/unit/test_util.py +3 -0
- ivoryos/routes/api/api.py +0 -56
- ivoryos-1.2.5.dist-info/RECORD +0 -100
- ivoryos-1.2.5.dist-info/top_level.txt +0 -1
- {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/WHEEL +0 -0
- {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import importlib
|
|
1
2
|
import os
|
|
2
3
|
from flask import Blueprint, request, current_app, send_file, flash, redirect, url_for, session, render_template
|
|
3
4
|
from flask_login import login_required
|
|
4
5
|
|
|
5
6
|
from ivoryos.utils import utils
|
|
6
|
-
from ivoryos.routes.control.utils import find_instrument_by_name
|
|
7
|
+
# from ivoryos.routes.control.utils import find_instrument_by_name
|
|
7
8
|
from ivoryos.utils.global_config import GlobalConfig
|
|
8
9
|
|
|
9
10
|
global_config = GlobalConfig()
|
|
@@ -28,16 +29,25 @@ def import_api():
|
|
|
28
29
|
# filepath.replace('\\', '/')
|
|
29
30
|
name = os.path.split(filepath)[-1].split('.')[0]
|
|
30
31
|
try:
|
|
31
|
-
spec =
|
|
32
|
-
module =
|
|
32
|
+
spec = importlib.util.spec_from_file_location(name, filepath)
|
|
33
|
+
module = importlib.util.module_from_spec(spec)
|
|
33
34
|
spec.loader.exec_module(module)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
cls_dict = utils.create_module_snapshot(module=module)
|
|
36
|
+
|
|
37
|
+
def merge_to_global(old: dict, new: dict):
|
|
38
|
+
overwritten = []
|
|
39
|
+
|
|
40
|
+
for key, value in new.items():
|
|
41
|
+
if key in old:
|
|
42
|
+
overwritten.append(key) # record duplicates
|
|
43
|
+
old[key] = value # overwrite or insert
|
|
44
|
+
|
|
45
|
+
return overwritten
|
|
46
|
+
|
|
47
|
+
duplicates = merge_to_global(global_config.api_variables, cls_dict)
|
|
48
|
+
if duplicates:
|
|
49
|
+
# optionally, you can log duplicates
|
|
50
|
+
flash(f"Overwritten classes: {', '.join(duplicates)}")
|
|
41
51
|
# should handle path error and file type error
|
|
42
52
|
except Exception as e:
|
|
43
53
|
flash(e.__str__())
|
|
@@ -107,7 +117,7 @@ def new_controller(instrument:str=None):
|
|
|
107
117
|
args = None
|
|
108
118
|
if instrument:
|
|
109
119
|
|
|
110
|
-
device =
|
|
120
|
+
device = global_config.api_variables[instrument]
|
|
111
121
|
args = utils.inspect.signature(device.__init__)
|
|
112
122
|
|
|
113
123
|
if request.method == 'POST':
|
|
@@ -49,6 +49,24 @@
|
|
|
49
49
|
</div>
|
|
50
50
|
</div>
|
|
51
51
|
{% endif %}
|
|
52
|
+
|
|
53
|
+
{% if block_variables %}
|
|
54
|
+
<div class="mb-4">
|
|
55
|
+
<h6 class="fw-bold text-secondary mb-2" style="letter-spacing: 1px;">Methods</h6>
|
|
56
|
+
<div class="list-group">
|
|
57
|
+
{% for inst in block_variables %}
|
|
58
|
+
<a class="list-group-item list-group-item-action d-flex align-items-center {% if instrument == inst %}active bg-warning text-dark border-0{% else %}bg-light{% endif %}"
|
|
59
|
+
href="{{ url_for('control.deck_controllers') }}?instrument={{ inst }}"
|
|
60
|
+
style="border-radius: 0.5rem; margin-bottom: 0.5rem; transition: background 0.2s;">
|
|
61
|
+
<span class="flex-grow-1">{{ inst | format_name }}</span>
|
|
62
|
+
{% if instrument == inst %}
|
|
63
|
+
<span class="ms-auto">></span>
|
|
64
|
+
{% endif %}
|
|
65
|
+
</a>
|
|
66
|
+
{% endfor %}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
{% endif %}
|
|
52
70
|
<!-- Action Buttons -->
|
|
53
71
|
<div class="mb-4">
|
|
54
72
|
<a href="{{ url_for('control.file.download_proxy', filetype='proxy') }}" class="btn btn-outline-primary w-100 mb-2">
|
|
@@ -89,6 +107,15 @@
|
|
|
89
107
|
{{ field(class="btn btn-dark") }}
|
|
90
108
|
{% elif field.type == "BooleanField" %}
|
|
91
109
|
{{ field(class="form-check-input") }}
|
|
110
|
+
{% elif field.type == "FlexibleEnumField" %}
|
|
111
|
+
<input type="text" id="{{ field.id }}" name="{{ field.name }}" value="{{ field.data }}"
|
|
112
|
+
list="{{ field.id }}_options" placeholder="{{ field.render_kw.placeholder if field.render_kw and field.render_kw.placeholder }}"
|
|
113
|
+
class="form-control">
|
|
114
|
+
<datalist id="{{ field.id }}_options">
|
|
115
|
+
{% for key in field.choices %}
|
|
116
|
+
<option value="{{ key }}">{{ key }}</option>
|
|
117
|
+
{% endfor %}
|
|
118
|
+
</datalist>
|
|
92
119
|
{% else %}
|
|
93
120
|
{{ field(class="form-control") }}
|
|
94
121
|
{% endif %}
|
ivoryos/routes/control/utils.py
CHANGED
|
@@ -13,6 +13,8 @@ def find_instrument_by_name(name: str):
|
|
|
13
13
|
if name.startswith("deck"):
|
|
14
14
|
name = name.replace("deck.", "")
|
|
15
15
|
return getattr(global_config.deck, name)
|
|
16
|
+
elif name.startswith("blocks"):
|
|
17
|
+
return global_config.building_blocks[name]
|
|
16
18
|
elif name in global_config.defined_variables:
|
|
17
19
|
return global_config.defined_variables[name]
|
|
18
20
|
elif name in globals():
|
ivoryos/routes/data/data.py
CHANGED
|
@@ -3,7 +3,7 @@ import os
|
|
|
3
3
|
from flask import Blueprint, redirect, url_for, request, render_template, current_app, jsonify, send_file
|
|
4
4
|
from flask_login import login_required
|
|
5
5
|
|
|
6
|
-
from ivoryos.utils.db_models import db, WorkflowRun, WorkflowStep
|
|
6
|
+
from ivoryos.utils.db_models import db, WorkflowRun, WorkflowStep, WorkflowPhase
|
|
7
7
|
|
|
8
8
|
data = Blueprint('data', __name__, template_folder='templates')
|
|
9
9
|
|
|
@@ -37,7 +37,6 @@ def list_workflows():
|
|
|
37
37
|
else:
|
|
38
38
|
return render_template('workflow_database.html', workflows=workflows)
|
|
39
39
|
|
|
40
|
-
|
|
41
40
|
@data.get("/executions/records/<int:workflow_id>")
|
|
42
41
|
def workflow_logs(workflow_id:int):
|
|
43
42
|
"""
|
|
@@ -45,52 +44,119 @@ def workflow_logs(workflow_id:int):
|
|
|
45
44
|
|
|
46
45
|
get workflow data logs by workflow id
|
|
47
46
|
|
|
48
|
-
.. http:get:: /executions/<int:workflow_id>
|
|
47
|
+
.. http:get:: /executions/records/<int:workflow_id>
|
|
49
48
|
|
|
50
49
|
:param workflow_id: workflow id
|
|
51
50
|
:type workflow_id: int
|
|
52
51
|
"""
|
|
52
|
+
workflow = db.session.get(WorkflowRun, workflow_id)
|
|
53
|
+
if not workflow:
|
|
54
|
+
return jsonify({"error": "Workflow not found"}), 404
|
|
55
|
+
|
|
56
|
+
# Query all phases for this run, ordered by start_time
|
|
57
|
+
phases = WorkflowPhase.query.filter_by(run_id=workflow_id).order_by(WorkflowPhase.start_time).all()
|
|
58
|
+
|
|
59
|
+
# Prepare grouped data for template (full objects)
|
|
60
|
+
grouped = {
|
|
61
|
+
"prep": [],
|
|
62
|
+
"script": {},
|
|
63
|
+
"cleanup": [],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Prepare grouped data for JSON (dicts)
|
|
67
|
+
grouped_json = {
|
|
68
|
+
"prep": [],
|
|
69
|
+
"script": {},
|
|
70
|
+
"cleanup": [],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for phase in phases:
|
|
74
|
+
phase_dict = phase.as_dict()
|
|
75
|
+
|
|
76
|
+
# Steps sorted by step_index
|
|
77
|
+
steps = sorted(phase.steps, key=lambda s: s.step_index)
|
|
78
|
+
phase_steps_dicts = [s.as_dict() for s in steps]
|
|
79
|
+
|
|
80
|
+
if phase.name == "prep":
|
|
81
|
+
grouped["prep"].append(phase)
|
|
82
|
+
grouped_json["prep"].append({
|
|
83
|
+
**phase_dict,
|
|
84
|
+
"steps": phase_steps_dicts
|
|
85
|
+
})
|
|
53
86
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
grouped = {
|
|
60
|
-
"prep": [],
|
|
61
|
-
"script": {},
|
|
62
|
-
"cleanup": [],
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
# Use dicts for JSON response
|
|
66
|
-
grouped_json = {
|
|
67
|
-
"prep": [],
|
|
68
|
-
"script": {},
|
|
69
|
-
"cleanup": [],
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
for step in steps:
|
|
73
|
-
step_dict = step.as_dict()
|
|
74
|
-
|
|
75
|
-
if step.phase == "prep":
|
|
76
|
-
grouped["prep"].append(step)
|
|
77
|
-
grouped_json["prep"].append(step_dict)
|
|
78
|
-
|
|
79
|
-
elif step.phase == "script":
|
|
80
|
-
grouped["script"].setdefault(step.repeat_index, []).append(step)
|
|
81
|
-
grouped_json["script"].setdefault(step.repeat_index, []).append(step_dict)
|
|
82
|
-
|
|
83
|
-
elif step.phase == "cleanup" or step.method_name == "stop":
|
|
84
|
-
grouped["cleanup"].append(step)
|
|
85
|
-
grouped_json["cleanup"].append(step_dict)
|
|
86
|
-
|
|
87
|
-
if request.accept_mimetypes.best_match(['application/json', 'text/html']) == 'application/json':
|
|
88
|
-
return jsonify({
|
|
89
|
-
"workflow_info": workflow.as_dict(),
|
|
90
|
-
"steps": grouped_json,
|
|
87
|
+
elif phase.name == "main":
|
|
88
|
+
grouped["script"].setdefault(phase.repeat_index, []).append(phase)
|
|
89
|
+
grouped_json["script"].setdefault(phase.repeat_index, []).append({
|
|
90
|
+
**phase_dict,
|
|
91
|
+
"steps": phase_steps_dicts
|
|
91
92
|
})
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
|
|
94
|
+
elif phase.name == "cleanup":
|
|
95
|
+
grouped["cleanup"].append(phase)
|
|
96
|
+
grouped_json["cleanup"].append({
|
|
97
|
+
**phase_dict,
|
|
98
|
+
"steps": phase_steps_dicts
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
if request.accept_mimetypes.best_match(['application/json', 'text/html']) == 'application/json':
|
|
102
|
+
return jsonify({
|
|
103
|
+
"workflow_info": workflow.as_dict(),
|
|
104
|
+
"phases": grouped_json,
|
|
105
|
+
})
|
|
106
|
+
else:
|
|
107
|
+
return render_template("workflow_view.html", workflow=workflow, grouped=grouped)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@data.get("/executions/data/<int:workflow_id>")
|
|
111
|
+
def workflow_phase_data(workflow_id: int):
|
|
112
|
+
"""
|
|
113
|
+
.. :quickref: Workflow Data Database; get workflow data for plotting
|
|
114
|
+
|
|
115
|
+
get workflow data for plotting by workflow id
|
|
116
|
+
|
|
117
|
+
.. http:get:: /executions/data/<int: workflow_id>
|
|
118
|
+
|
|
119
|
+
:param workflow_id: workflow id
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
workflow = db.session.get(WorkflowRun, workflow_id)
|
|
123
|
+
if not workflow:
|
|
124
|
+
return jsonify({})
|
|
125
|
+
|
|
126
|
+
phase_data = {}
|
|
127
|
+
main_phases = WorkflowPhase.query.filter_by(run_id=workflow_id, name='main') \
|
|
128
|
+
.order_by(WorkflowPhase.repeat_index).all()
|
|
129
|
+
|
|
130
|
+
for phase in main_phases:
|
|
131
|
+
outputs = phase.outputs or {}
|
|
132
|
+
phase_index = phase.repeat_index
|
|
133
|
+
phase_data[phase_index] = {}
|
|
134
|
+
|
|
135
|
+
# Normalize everything to a list of dicts
|
|
136
|
+
if isinstance(outputs, dict):
|
|
137
|
+
outputs = [outputs]
|
|
138
|
+
elif isinstance(outputs, list):
|
|
139
|
+
# flatten if it’s nested like [[{...}, {...}]]
|
|
140
|
+
outputs = [
|
|
141
|
+
item for sublist in outputs
|
|
142
|
+
for item in (sublist if isinstance(sublist, list) else [sublist])
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
# convert each output entry to plotting format
|
|
146
|
+
for out in outputs:
|
|
147
|
+
if not isinstance(out, dict):
|
|
148
|
+
continue
|
|
149
|
+
for k, v in out.items():
|
|
150
|
+
if isinstance(v, (int, float)):
|
|
151
|
+
phase_data[phase_index].setdefault(k, []).append(
|
|
152
|
+
{"x": phase_index, "y": v}
|
|
153
|
+
)
|
|
154
|
+
elif isinstance(v, list) and all(isinstance(i, (int, float)) for i in v):
|
|
155
|
+
phase_data[phase_index].setdefault(k, []).extend(
|
|
156
|
+
{"x": phase_index, "y": val} for val in v
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return jsonify(phase_data)
|
|
94
160
|
|
|
95
161
|
|
|
96
162
|
@data.delete("/executions/records/<int:workflow_id>")
|
|
@@ -101,7 +167,7 @@ def delete_workflow_record(workflow_id: int):
|
|
|
101
167
|
|
|
102
168
|
delete a workflow execution record by workflow id
|
|
103
169
|
|
|
104
|
-
.. http:delete:: /executions/records/<int:workflow_id>
|
|
170
|
+
.. http:delete:: /executions/records/<int: workflow_id>
|
|
105
171
|
|
|
106
172
|
:param workflow_id: workflow id
|
|
107
173
|
:type workflow_id: int
|
|
@@ -124,7 +190,7 @@ def download_results(filename:str):
|
|
|
124
190
|
:param filename: workflow data filename
|
|
125
191
|
:type filename: str
|
|
126
192
|
|
|
127
|
-
# :status 302: load pseudo deck and then redirects to :http:get:`/ivoryos/executions`
|
|
193
|
+
# :status 302: load pseudo deck and then redirects to :http:get:`/ivoryos/executions/records`
|
|
128
194
|
"""
|
|
129
195
|
|
|
130
196
|
filepath = os.path.join(current_app.config["DATA_FOLDER"], filename)
|
|
@@ -1,13 +1,78 @@
|
|
|
1
|
-
<div class="card mb-2 {{ 'border-danger text-danger bg-light' if
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
</
|
|
1
|
+
<div class="card mb-2 {{ 'border-danger text-danger bg-light' if phase.run_error else 'border-secondary' }}">
|
|
2
|
+
<div class="card-body p-2">
|
|
3
|
+
<small class="text-muted">
|
|
4
|
+
<i class="fas fa-play-circle me-1"></i> Start: {{ phase.start_time.strftime('%H:%M:%S') if phase.start_time else 'N/A' }}
|
|
5
|
+
<i class="fas fa-stop-circle ms-2 me-1"></i> End: {{ phase.end_time.strftime('%H:%M:%S') if phase.end_time else 'N/A' }}
|
|
6
|
+
</small>
|
|
7
|
+
{% if phase.parameters %}
|
|
8
|
+
<div class="mt-2">
|
|
9
|
+
<strong>Parameters: </strong>
|
|
10
|
+
{% if phase.parameters is mapping %}
|
|
11
|
+
{% if phase.parameters %}
|
|
12
|
+
<div class="mt-2">
|
|
13
|
+
<strong>Parameters: </strong>
|
|
14
|
+
{% for key, value in phase.parameters.items() %}
|
|
15
|
+
<span class="badge bg-secondary me-1">{{ key }}: {{ value }}</span>
|
|
16
|
+
{% endfor %}
|
|
17
|
+
</div>
|
|
18
|
+
{% endif %}
|
|
19
|
+
{% else %}
|
|
20
|
+
{% for batch in phase.parameters %}
|
|
21
|
+
<div class="mt-1">
|
|
22
|
+
<span class="badge bg-info text-dark me-1">Batch {{ loop.index }}</span>
|
|
23
|
+
{% for key, value in batch.items() %}
|
|
24
|
+
<span class="badge bg-secondary me-1">{{ key }}: {{ value }}</span>
|
|
25
|
+
{% endfor %}
|
|
26
|
+
</div>
|
|
27
|
+
{% endfor %}
|
|
28
|
+
{% endif %}
|
|
29
|
+
</div>
|
|
30
|
+
{% endif %}
|
|
31
|
+
|
|
32
|
+
{% if phase.steps %}
|
|
33
|
+
<div class="mt-2">
|
|
34
|
+
<strong>Steps:</strong>
|
|
35
|
+
<ul class="mb-0">
|
|
36
|
+
{% for step in phase.steps %}
|
|
37
|
+
<li class="{{ 'text-danger' if step.run_error else '' }}">
|
|
38
|
+
{{ step.method_name }}
|
|
39
|
+
<small class="text-muted">
|
|
40
|
+
({{ step.start_time.strftime('%H:%M:%S') if step.start_time else 'N/A' }} –
|
|
41
|
+
{{ step.end_time.strftime('%H:%M:%S') if step.end_time else 'N/A' }})
|
|
42
|
+
</small>
|
|
43
|
+
</li>
|
|
44
|
+
{% endfor %}
|
|
45
|
+
</ul>
|
|
46
|
+
</div>
|
|
47
|
+
{% endif %}
|
|
48
|
+
{% if phase.outputs %}
|
|
49
|
+
<div class="mt-2">
|
|
50
|
+
<strong>Outputs:</strong>
|
|
51
|
+
|
|
52
|
+
{% if phase.outputs is mapping %}
|
|
53
|
+
{% for key, value in phase.outputs.items() %}
|
|
54
|
+
<span class="badge bg-success me-1">{{ key }}: {{ value }}</span>
|
|
55
|
+
{% endfor %}
|
|
56
|
+
|
|
57
|
+
{% elif phase.outputs is sequence %}
|
|
58
|
+
{% for batch in phase.outputs %}
|
|
59
|
+
<div class="mt-1">
|
|
60
|
+
<span class="badge bg-info text-dark me-1">Batch {{ loop.index }}</span>
|
|
61
|
+
{% if batch is mapping %}
|
|
62
|
+
{% for key, value in batch.items() %}
|
|
63
|
+
<span class="badge bg-success me-1">{{ key }}: {{ value }}</span>
|
|
64
|
+
{% endfor %}
|
|
65
|
+
{% elif batch is sequence %}
|
|
66
|
+
{% for kwargs in batch %}
|
|
67
|
+
{% for key, value in kwargs.items() %}
|
|
68
|
+
<span class="badge bg-success me-1">{{ key }}: {{ value }}</span>
|
|
69
|
+
{% endfor %}
|
|
70
|
+
{% endfor %}
|
|
71
|
+
{% endif %}
|
|
72
|
+
</div>
|
|
73
|
+
{% endfor %}
|
|
74
|
+
{% endif %}
|
|
75
|
+
</div>
|
|
76
|
+
{% endif %}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|