fraclab-sdk 0.1.0__py3-none-any.whl → 0.1.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.
- CHANGELOG.md +50 -0
- README.md +73 -7
- fraclab_sdk/__init__.py +3 -0
- fraclab_sdk/devkit/__init__.py +8 -0
- fraclab_sdk/devkit/validate.py +836 -75
- fraclab_sdk/specs/__init__.py +22 -0
- fraclab_sdk/specs/output.py +33 -0
- fraclab_sdk/version.py +5 -0
- fraclab_sdk/workbench/Home.py +162 -0
- fraclab_sdk/workbench/__init__.py +4 -0
- fraclab_sdk/workbench/__main__.py +48 -0
- fraclab_sdk/workbench/pages/1_Snapshots.py +577 -0
- fraclab_sdk/workbench/pages/2_Browse.py +513 -0
- fraclab_sdk/workbench/pages/3_Selection.py +464 -0
- fraclab_sdk/workbench/pages/4_Run.py +331 -0
- fraclab_sdk/workbench/pages/5_Results.py +298 -0
- fraclab_sdk/workbench/pages/6_Algorithm_Edit.py +116 -0
- fraclab_sdk/workbench/pages/7_Schema_Edit.py +160 -0
- fraclab_sdk/workbench/pages/8_Output_Edit.py +155 -0
- fraclab_sdk/workbench/pages/9_Export_Algorithm.py +386 -0
- fraclab_sdk/workbench/pages/__init__.py +1 -0
- fraclab_sdk/workbench/ui_styles.py +103 -0
- fraclab_sdk/workbench/utils.py +43 -0
- {fraclab_sdk-0.1.0.dist-info → fraclab_sdk-0.1.2.dist-info}/METADATA +77 -8
- {fraclab_sdk-0.1.0.dist-info → fraclab_sdk-0.1.2.dist-info}/RECORD +27 -8
- {fraclab_sdk-0.1.0.dist-info → fraclab_sdk-0.1.2.dist-info}/entry_points.txt +1 -0
- {fraclab_sdk-0.1.0.dist-info → fraclab_sdk-0.1.2.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Run page: edit params for existing runs and execute."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
import streamlit as st
|
|
8
|
+
|
|
9
|
+
from fraclab_sdk.algorithm import AlgorithmLibrary
|
|
10
|
+
from fraclab_sdk.config import SDKConfig
|
|
11
|
+
from fraclab_sdk.run import RunManager, RunStatus
|
|
12
|
+
from fraclab_sdk.workbench import ui_styles
|
|
13
|
+
|
|
14
|
+
st.set_page_config(page_title="Run", page_icon="▶️", layout="wide", initial_sidebar_state="expanded")
|
|
15
|
+
st.title("Run")
|
|
16
|
+
|
|
17
|
+
ui_styles.apply_global_styles()
|
|
18
|
+
|
|
19
|
+
# Guidance: if no params UI shows up, validate InputSpec via the editor page.
|
|
20
|
+
st.info(
|
|
21
|
+
"看不到参数输入框?请在 Schema Editor 页面点击 Validate 检查 "
|
|
22
|
+
"`schema/inputspec.py`,确保生成的 schema 可用。",
|
|
23
|
+
icon="ℹ️",
|
|
24
|
+
)
|
|
25
|
+
# --- Page-Specific CSS ---
|
|
26
|
+
st.markdown("""
|
|
27
|
+
<style>
|
|
28
|
+
/* Compact form labels */
|
|
29
|
+
div[data-testid="stNumberInput"] label,
|
|
30
|
+
div[data-testid="stTextInput"] label,
|
|
31
|
+
div[data-testid="stCheckbox"] label {
|
|
32
|
+
margin-bottom: 0px !important;
|
|
33
|
+
font-size: 0.85rem !important;
|
|
34
|
+
color: #666 !important;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Divider spacing */
|
|
38
|
+
hr { margin-top: 1rem; margin-bottom: 1rem; }
|
|
39
|
+
</style>
|
|
40
|
+
""", unsafe_allow_html=True)
|
|
41
|
+
|
|
42
|
+
config = SDKConfig()
|
|
43
|
+
run_mgr = RunManager(config)
|
|
44
|
+
algo_lib = AlgorithmLibrary(config)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- Intelligent Layout Engine ---
|
|
48
|
+
|
|
49
|
+
def _is_compact_field(schema: dict) -> bool:
|
|
50
|
+
"""Determine if a field is small enough to fit in a grid column."""
|
|
51
|
+
ftype = schema.get("type")
|
|
52
|
+
# Numbers, Booleans, and short Strings (without enums/long defaults) are compact
|
|
53
|
+
if ftype in ["integer", "number", "boolean"]:
|
|
54
|
+
return True
|
|
55
|
+
if ftype == "string" and len(str(schema.get("default", ""))) < 50:
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def render_field_widget(key: str, schema: dict, value: Any, path: str) -> Any:
|
|
60
|
+
"""Render a single widget based on schema type."""
|
|
61
|
+
ftype = schema.get("type")
|
|
62
|
+
title = schema.get("title") or key
|
|
63
|
+
# Simplify label: if title is camelCase, maybe split it? For now use title directly.
|
|
64
|
+
# description = schema.get("description") # Tooltip is enough, don't clutter UI text
|
|
65
|
+
|
|
66
|
+
default_val = schema.get("default")
|
|
67
|
+
help_text = schema.get("description")
|
|
68
|
+
|
|
69
|
+
if ftype == "string":
|
|
70
|
+
val = value if value is not None else (default_val or "")
|
|
71
|
+
return st.text_input(title, value=val, help=help_text, key=path)
|
|
72
|
+
|
|
73
|
+
if ftype == "number":
|
|
74
|
+
val = value if value is not None else default_val
|
|
75
|
+
return st.number_input(title, value=float(val or 0.0), help=help_text, key=path)
|
|
76
|
+
|
|
77
|
+
if ftype == "integer":
|
|
78
|
+
val = value if value is not None else default_val
|
|
79
|
+
return int(st.number_input(title, value=int(val or 0), step=1, help=help_text, key=path))
|
|
80
|
+
|
|
81
|
+
if ftype == "boolean":
|
|
82
|
+
val = value if value is not None else default_val
|
|
83
|
+
# Toggle looks better than checkbox in grid
|
|
84
|
+
return st.toggle(title, value=bool(val), help=help_text, key=path)
|
|
85
|
+
|
|
86
|
+
if ftype == "array":
|
|
87
|
+
# Arrays are complex, stick to full width expansion
|
|
88
|
+
return _render_json_editor(title, value, default_val, help_text, path)
|
|
89
|
+
|
|
90
|
+
if ftype == "object":
|
|
91
|
+
# Nested object -> Recursive layout
|
|
92
|
+
with st.container(border=True):
|
|
93
|
+
st.markdown(f"**{title}**")
|
|
94
|
+
props = schema.get("properties", {})
|
|
95
|
+
obj = value if isinstance(value, dict) else (default_val if isinstance(default_val, dict) else {})
|
|
96
|
+
|
|
97
|
+
# Recursive call to grid layout
|
|
98
|
+
return render_schema_grid(props, obj, path)
|
|
99
|
+
|
|
100
|
+
# Fallback
|
|
101
|
+
return _render_json_editor(title, value, default_val, help_text, path)
|
|
102
|
+
|
|
103
|
+
def _render_json_editor(title, value, default, help_text, path):
|
|
104
|
+
"""Helper for raw JSON fields."""
|
|
105
|
+
st.markdown(f"<small>{title}</small>", unsafe_allow_html=True)
|
|
106
|
+
current = value if value is not None else (default if default is not None else [])
|
|
107
|
+
text = st.text_area(
|
|
108
|
+
title,
|
|
109
|
+
value=json.dumps(current, indent=2, ensure_ascii=False),
|
|
110
|
+
help=f"{help_text} (Edit as JSON)",
|
|
111
|
+
key=path,
|
|
112
|
+
label_visibility="collapsed",
|
|
113
|
+
height=100
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
return json.loads(text) if text.strip() else current
|
|
117
|
+
except Exception:
|
|
118
|
+
return current
|
|
119
|
+
|
|
120
|
+
def render_schema_grid(properties: Dict[str, dict], current_values: Dict[str, Any], prefix: str) -> Dict[str, Any]:
|
|
121
|
+
"""
|
|
122
|
+
Renders fields in a smart grid layout:
|
|
123
|
+
- Compact fields (numbers, bools) get packed into columns (up to 4).
|
|
124
|
+
- Wide fields (objects, arrays) break the line and take full width.
|
|
125
|
+
"""
|
|
126
|
+
result = {}
|
|
127
|
+
|
|
128
|
+
# 1. Separate fields into groups to maintain partial order while grid-packing
|
|
129
|
+
# Strategy: Iterate and buffer compact fields. Flush buffer when a wide field hits.
|
|
130
|
+
|
|
131
|
+
compact_buffer = [] # List of (key, schema)
|
|
132
|
+
|
|
133
|
+
def flush_buffer():
|
|
134
|
+
if not compact_buffer:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
# Calculate optimal columns (max 4, min 2)
|
|
138
|
+
n_items = len(compact_buffer)
|
|
139
|
+
n_cols = 4 if n_items >= 4 else (n_items if n_items > 0 else 1)
|
|
140
|
+
|
|
141
|
+
# Split into rows if > 4 items? Simple logic: Just wrap
|
|
142
|
+
# Actually st.columns handles wrapping poorly, better to batch by 4
|
|
143
|
+
|
|
144
|
+
for i in range(0, n_items, 4):
|
|
145
|
+
batch = compact_buffer[i : i+4]
|
|
146
|
+
cols = st.columns(len(batch))
|
|
147
|
+
for col, (b_key, b_schema) in zip(cols, batch):
|
|
148
|
+
with col:
|
|
149
|
+
val = current_values.get(b_key)
|
|
150
|
+
result[b_key] = render_field_widget(b_key, b_schema, val, f"{prefix}.{b_key}")
|
|
151
|
+
|
|
152
|
+
compact_buffer.clear()
|
|
153
|
+
|
|
154
|
+
for key, prop_schema in properties.items():
|
|
155
|
+
if _is_compact_field(prop_schema):
|
|
156
|
+
compact_buffer.append((key, prop_schema))
|
|
157
|
+
else:
|
|
158
|
+
# Wide field encountered: flush buffer first
|
|
159
|
+
flush_buffer()
|
|
160
|
+
# Render wide field
|
|
161
|
+
val = current_values.get(key)
|
|
162
|
+
result[key] = render_field_widget(key, prop_schema, val, f"{prefix}.{key}")
|
|
163
|
+
|
|
164
|
+
# Final flush
|
|
165
|
+
flush_buffer()
|
|
166
|
+
|
|
167
|
+
# Preserve extra keys in current_values that aren't in schema
|
|
168
|
+
for k, v in current_values.items():
|
|
169
|
+
if k not in result:
|
|
170
|
+
result[k] = v
|
|
171
|
+
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_params(run_dir: Path) -> dict:
|
|
176
|
+
path = run_dir / "input" / "params.json"
|
|
177
|
+
try:
|
|
178
|
+
return json.loads(path.read_text())
|
|
179
|
+
except Exception:
|
|
180
|
+
return {}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ==========================================
|
|
184
|
+
# Main Logic
|
|
185
|
+
# ==========================================
|
|
186
|
+
|
|
187
|
+
runs = run_mgr.list_runs()
|
|
188
|
+
|
|
189
|
+
if not runs:
|
|
190
|
+
st.info("No runs available. Create a run from the Selection page.")
|
|
191
|
+
st.stop()
|
|
192
|
+
|
|
193
|
+
pending_runs = [r for r in runs if r.status == RunStatus.PENDING]
|
|
194
|
+
other_runs = [r for r in runs if r.status != RunStatus.PENDING]
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------
|
|
197
|
+
# 1. Pending Runs (Editor Workspace)
|
|
198
|
+
# ------------------------------------------
|
|
199
|
+
st.subheader("Pending Runs")
|
|
200
|
+
|
|
201
|
+
if not pending_runs:
|
|
202
|
+
st.info("No pending runs waiting for execution.")
|
|
203
|
+
else:
|
|
204
|
+
# Use tabs for context switching
|
|
205
|
+
tabs = st.tabs([f"⚙️ {run.run_id}" for run in pending_runs])
|
|
206
|
+
|
|
207
|
+
for tab, run in zip(tabs, pending_runs):
|
|
208
|
+
with tab:
|
|
209
|
+
run_dir = run_mgr.get_run_dir(run.run_id)
|
|
210
|
+
algo_handle = algo_lib.get_algorithm(run.algorithm_id, run.algorithm_version)
|
|
211
|
+
schema = algo_handle.params_schema
|
|
212
|
+
current_params = load_params(run_dir)
|
|
213
|
+
|
|
214
|
+
# --- Layout: Top Info Bar ---
|
|
215
|
+
with st.container(border=True):
|
|
216
|
+
c1, c2, c3, c4 = st.columns([2, 2, 2, 1])
|
|
217
|
+
with c1: st.caption(f"**Snapshot:** `{run.snapshot_id}`")
|
|
218
|
+
with c2: st.caption(f"**Algo:** `{run.algorithm_id}` v{run.algorithm_version}")
|
|
219
|
+
with c3: st.caption(f"**Created:** {run.created_at}")
|
|
220
|
+
with c4:
|
|
221
|
+
# Timeout setting tucked away here
|
|
222
|
+
timeout = st.number_input("Timeout (s)", value=300, step=10, key=f"to_{run.run_id}", label_visibility="collapsed")
|
|
223
|
+
|
|
224
|
+
# --- Layout: Parameters Grid ---
|
|
225
|
+
st.markdown("##### Parameters")
|
|
226
|
+
with st.container(border=True):
|
|
227
|
+
if schema.get("type") == "object":
|
|
228
|
+
props = schema.get("properties", {})
|
|
229
|
+
# CALL THE GRID ENGINE
|
|
230
|
+
new_params = render_schema_grid(props, current_params, prefix=f"run_{run.run_id}")
|
|
231
|
+
else:
|
|
232
|
+
st.info("Schema is not an object, editing raw JSON.")
|
|
233
|
+
new_params = _render_json_editor("Raw Params", current_params, {}, "", f"run_raw_{run.run_id}")
|
|
234
|
+
|
|
235
|
+
st.divider()
|
|
236
|
+
|
|
237
|
+
# --- Layout: Action Footer ---
|
|
238
|
+
# Right-aligned actions
|
|
239
|
+
_, col_btns = st.columns([3, 4])
|
|
240
|
+
with col_btns:
|
|
241
|
+
b1, b2, b3 = st.columns([1, 1, 1.5], gap="small")
|
|
242
|
+
|
|
243
|
+
with b1:
|
|
244
|
+
if st.button("🚫 Cancel", key=f"cancel_{run.run_id}", width="stretch"):
|
|
245
|
+
try:
|
|
246
|
+
run_mgr.delete_run(run.run_id)
|
|
247
|
+
st.success("Cancelled")
|
|
248
|
+
st.rerun()
|
|
249
|
+
except Exception as e:
|
|
250
|
+
st.error(f"Error: {e}")
|
|
251
|
+
|
|
252
|
+
with b2:
|
|
253
|
+
if st.button("💾 Save", key=f"save_{run.run_id}", width="stretch"):
|
|
254
|
+
try:
|
|
255
|
+
(run_dir / "input").mkdir(parents=True, exist_ok=True)
|
|
256
|
+
(run_dir / "input" / "params.json").write_text(
|
|
257
|
+
json.dumps(new_params, indent=2, ensure_ascii=False),
|
|
258
|
+
encoding="utf-8",
|
|
259
|
+
)
|
|
260
|
+
st.toast("Parameters saved successfully!", icon="💾")
|
|
261
|
+
except Exception as e:
|
|
262
|
+
st.error(f"Save failed: {e}")
|
|
263
|
+
|
|
264
|
+
with b3:
|
|
265
|
+
if st.button("▶️ Run Algorithm", key=f"exec_{run.run_id}", type="primary", width="stretch"):
|
|
266
|
+
try:
|
|
267
|
+
# Auto-save before run
|
|
268
|
+
(run_dir / "input").mkdir(parents=True, exist_ok=True)
|
|
269
|
+
(run_dir / "input" / "params.json").write_text(
|
|
270
|
+
json.dumps(new_params, indent=2, ensure_ascii=False),
|
|
271
|
+
encoding="utf-8",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
with st.spinner("Initializing execution..."):
|
|
275
|
+
result = run_mgr.execute(run.run_id, timeout_s=int(timeout))
|
|
276
|
+
|
|
277
|
+
if result.error:
|
|
278
|
+
st.error(f"Run Finished: {result.status.value}\n{result.error}")
|
|
279
|
+
else:
|
|
280
|
+
# Navigate to Results page with executed run
|
|
281
|
+
st.session_state.executed_run_id = run.run_id
|
|
282
|
+
st.switch_page("pages/5_Results.py")
|
|
283
|
+
except Exception as e:
|
|
284
|
+
st.error(f"Execution Exception: {e}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ------------------------------------------
|
|
288
|
+
# 2. History (Other Runs)
|
|
289
|
+
# ------------------------------------------
|
|
290
|
+
st.subheader("Run History")
|
|
291
|
+
|
|
292
|
+
if not other_runs:
|
|
293
|
+
st.caption("No historical runs.")
|
|
294
|
+
|
|
295
|
+
other_runs_reversed = other_runs[::-1]
|
|
296
|
+
|
|
297
|
+
for run in other_runs_reversed:
|
|
298
|
+
status_config = {
|
|
299
|
+
RunStatus.PENDING: ("⏳", "Pending", "gray"),
|
|
300
|
+
RunStatus.RUNNING: ("🔄", "Running", "blue"),
|
|
301
|
+
RunStatus.SUCCEEDED: ("✅", "Succeeded", "green"),
|
|
302
|
+
RunStatus.FAILED: ("❌", "Failed", "red"),
|
|
303
|
+
RunStatus.TIMEOUT: ("⏱️", "Timeout", "orange"),
|
|
304
|
+
}
|
|
305
|
+
icon, label, color = status_config.get(run.status, ("❓", "Unknown", "gray"))
|
|
306
|
+
|
|
307
|
+
with st.expander(f"{icon} {run.run_id}", expanded=False):
|
|
308
|
+
with st.container(border=True):
|
|
309
|
+
# Info
|
|
310
|
+
c1, c2, c3 = st.columns([3, 2, 2])
|
|
311
|
+
with c1:
|
|
312
|
+
st.caption("Context")
|
|
313
|
+
st.markdown(f"**{run.algorithm_id}** v{run.algorithm_version}")
|
|
314
|
+
st.text(f"Snap: {run.snapshot_id}")
|
|
315
|
+
with c2:
|
|
316
|
+
st.caption("Timing")
|
|
317
|
+
st.text(f"Start: {run.started_at or '--'}")
|
|
318
|
+
st.text(f"End: {run.completed_at or '--'}")
|
|
319
|
+
with c3:
|
|
320
|
+
st.caption("Status")
|
|
321
|
+
st.markdown(f":{color}[**{label}**]")
|
|
322
|
+
if run.error:
|
|
323
|
+
st.error(run.error)
|
|
324
|
+
|
|
325
|
+
# Params Read-only
|
|
326
|
+
st.divider()
|
|
327
|
+
st.caption("Run Parameters")
|
|
328
|
+
run_dir = run_mgr.get_run_dir(run.run_id)
|
|
329
|
+
params_view = load_params(run_dir)
|
|
330
|
+
if params_view:
|
|
331
|
+
st.code(json.dumps(params_view, indent=2, ensure_ascii=False), language="json")
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Results viewing page."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import streamlit as st
|
|
6
|
+
|
|
7
|
+
from fraclab_sdk.config import SDKConfig
|
|
8
|
+
from fraclab_sdk.algorithm import AlgorithmLibrary
|
|
9
|
+
from fraclab_sdk.results import (
|
|
10
|
+
ResultReader,
|
|
11
|
+
get_artifact_preview_type,
|
|
12
|
+
preview_image,
|
|
13
|
+
preview_json_raw,
|
|
14
|
+
preview_json_table,
|
|
15
|
+
preview_scalar,
|
|
16
|
+
)
|
|
17
|
+
from fraclab_sdk.run import RunManager, RunStatus
|
|
18
|
+
from fraclab_sdk.workbench import ui_styles
|
|
19
|
+
|
|
20
|
+
st.set_page_config(page_title="Results", page_icon="📊", layout="wide", initial_sidebar_state="expanded")
|
|
21
|
+
st.title("Results")
|
|
22
|
+
|
|
23
|
+
ui_styles.apply_global_styles()
|
|
24
|
+
|
|
25
|
+
# Guidance: if artifacts preview looks empty, validate OutputContract via the editor page.
|
|
26
|
+
st.info(
|
|
27
|
+
"看不到期望的输出?请在 Output Editor 页面点击 Validate 检查 "
|
|
28
|
+
"`schema/output_contract.py` 的 datasets/items/artifacts 是否正确。",
|
|
29
|
+
icon="ℹ️",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def get_manager():
|
|
33
|
+
"""Get run manager."""
|
|
34
|
+
config = SDKConfig()
|
|
35
|
+
return RunManager(config)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
run_manager = get_manager()
|
|
39
|
+
algo_lib = AlgorithmLibrary(run_manager._config)
|
|
40
|
+
runs = run_manager.list_runs()
|
|
41
|
+
|
|
42
|
+
if not runs:
|
|
43
|
+
st.info("No runs available.")
|
|
44
|
+
st.stop()
|
|
45
|
+
|
|
46
|
+
# ==========================================
|
|
47
|
+
# 1. Run Selection & Status
|
|
48
|
+
# ==========================================
|
|
49
|
+
|
|
50
|
+
# Prepare options
|
|
51
|
+
run_options = {r.run_id: r for r in runs}
|
|
52
|
+
# Sort by latest first usually makes sense
|
|
53
|
+
run_ids = list(reversed(list(run_options.keys())))
|
|
54
|
+
|
|
55
|
+
# Check for navigation context
|
|
56
|
+
default_run_id = st.session_state.pop("executed_run_id", None) or st.session_state.pop("created_run_id", None)
|
|
57
|
+
default_index = run_ids.index(default_run_id) if default_run_id in run_ids else 0
|
|
58
|
+
|
|
59
|
+
with st.container(border=True):
|
|
60
|
+
col_sel, col_stat = st.columns([4, 1])
|
|
61
|
+
|
|
62
|
+
with col_sel:
|
|
63
|
+
selected_run_id = st.selectbox(
|
|
64
|
+
"Select Run",
|
|
65
|
+
options=run_ids,
|
|
66
|
+
index=default_index,
|
|
67
|
+
format_func=lambda x: f"{x} — {run_options[x].algorithm_id} (v{run_options[x].algorithm_version})",
|
|
68
|
+
label_visibility="collapsed"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
with col_stat:
|
|
72
|
+
if selected_run_id:
|
|
73
|
+
status = run_options[selected_run_id].status
|
|
74
|
+
status_color = {
|
|
75
|
+
RunStatus.SUCCEEDED: "green",
|
|
76
|
+
RunStatus.FAILED: "red",
|
|
77
|
+
RunStatus.PENDING: "gray",
|
|
78
|
+
RunStatus.RUNNING: "blue"
|
|
79
|
+
}.get(status, "gray")
|
|
80
|
+
st.markdown(f"<div style='text-align:center; padding: 8px; border: 1px solid #ddd; border-radius: 6px;'>Status: <b style='color:{status_color}'>{status.value}</b></div>", unsafe_allow_html=True)
|
|
81
|
+
|
|
82
|
+
if not selected_run_id:
|
|
83
|
+
st.stop()
|
|
84
|
+
|
|
85
|
+
run = run_options[selected_run_id]
|
|
86
|
+
run_dir = run_manager.get_run_dir(selected_run_id)
|
|
87
|
+
reader = ResultReader(run_dir)
|
|
88
|
+
|
|
89
|
+
# ==========================================
|
|
90
|
+
# 2. Run Context
|
|
91
|
+
# ==========================================
|
|
92
|
+
|
|
93
|
+
def _load_output_contract(algo_id: str, algo_version: str):
|
|
94
|
+
"""Load output_contract.json from algorithm directory."""
|
|
95
|
+
try:
|
|
96
|
+
handle = algo_lib.get_algorithm(algo_id, algo_version)
|
|
97
|
+
manifest_path = handle.directory / "manifest.json"
|
|
98
|
+
manifest_data = {}
|
|
99
|
+
if manifest_path.exists():
|
|
100
|
+
manifest_data = json.loads(manifest_path.read_text())
|
|
101
|
+
files = manifest_data.get("files") or {}
|
|
102
|
+
rel = files.get("outputContractPath", "dist/output_contract.json")
|
|
103
|
+
contract_path = (handle.directory / rel).resolve()
|
|
104
|
+
if not contract_path.exists():
|
|
105
|
+
return None
|
|
106
|
+
return json.loads(contract_path.read_text())
|
|
107
|
+
except Exception:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
output_contract = _load_output_contract(run.algorithm_id, run.algorithm_version)
|
|
111
|
+
|
|
112
|
+
with st.expander("ℹ️ Run Metadata", expanded=False):
|
|
113
|
+
c1, c2, c3 = st.columns(3)
|
|
114
|
+
with c1:
|
|
115
|
+
st.caption("Snapshot ID")
|
|
116
|
+
st.code(run.snapshot_id, language="text")
|
|
117
|
+
with c2:
|
|
118
|
+
st.caption("Algorithm ID")
|
|
119
|
+
st.code(run.algorithm_id, language="text")
|
|
120
|
+
with c3:
|
|
121
|
+
st.caption("Timestamps")
|
|
122
|
+
st.text(f"Start: {run.started_at}\nEnd: {run.completed_at}")
|
|
123
|
+
|
|
124
|
+
if run.error:
|
|
125
|
+
st.error(f"Run Error: {run.error}")
|
|
126
|
+
elif reader.has_manifest() and reader.get_error():
|
|
127
|
+
st.error(f"Manifest Error: {reader.get_error()}")
|
|
128
|
+
|
|
129
|
+
st.divider()
|
|
130
|
+
|
|
131
|
+
# ==========================================
|
|
132
|
+
# 3. Artifacts (Default Expanded)
|
|
133
|
+
# ==========================================
|
|
134
|
+
st.subheader("Artifacts")
|
|
135
|
+
|
|
136
|
+
if not reader.has_manifest():
|
|
137
|
+
st.warning("⚠️ Output manifest not found (Run may have failed or produced no output)")
|
|
138
|
+
else:
|
|
139
|
+
manifest = reader.read_manifest()
|
|
140
|
+
|
|
141
|
+
if not manifest.datasets:
|
|
142
|
+
st.info("No artifacts produced.")
|
|
143
|
+
else:
|
|
144
|
+
for ds in manifest.datasets:
|
|
145
|
+
# Match with contract
|
|
146
|
+
contract_ds = None
|
|
147
|
+
if output_contract:
|
|
148
|
+
contract_ds = next((d for d in output_contract.get("datasets", []) if d.get("key") == ds.datasetKey), None)
|
|
149
|
+
|
|
150
|
+
header = f"📂 {ds.datasetKey}"
|
|
151
|
+
if contract_ds and contract_ds.get("role"):
|
|
152
|
+
header += f" ({contract_ds.get('role')})"
|
|
153
|
+
|
|
154
|
+
# --- DATASET LEVEL: EXPANDED BY DEFAULT ---
|
|
155
|
+
with st.expander(header, expanded=True):
|
|
156
|
+
# Contract Info Bar
|
|
157
|
+
if contract_ds:
|
|
158
|
+
st.caption(
|
|
159
|
+
f"**Schema Definition:** Kind=`{contract_ds.get('kind')}` • "
|
|
160
|
+
f"Owner=`{contract_ds.get('owner')}` • "
|
|
161
|
+
f"Card=`{contract_ds.get('cardinality')}`"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# --- ITEMS LEVEL: CARDS (Always Visible) ---
|
|
165
|
+
for item in ds.items:
|
|
166
|
+
art = item.artifact
|
|
167
|
+
preview_type = get_artifact_preview_type(art)
|
|
168
|
+
|
|
169
|
+
with st.container(border=True):
|
|
170
|
+
# Item Header & Metadata
|
|
171
|
+
m1, m2, m3 = st.columns([2, 2, 3])
|
|
172
|
+
with m1:
|
|
173
|
+
st.markdown(f"**Item:** `{item.itemKey or art.artifactKey}`")
|
|
174
|
+
with m2:
|
|
175
|
+
st.caption(f"Type: `{art.artifactType}`")
|
|
176
|
+
with m3:
|
|
177
|
+
if art.mimeType: st.caption(f"MIME: `{art.mimeType}`")
|
|
178
|
+
|
|
179
|
+
# [Modified] 删除了 Owner 和 Dims 的显示
|
|
180
|
+
st.markdown("---")
|
|
181
|
+
|
|
182
|
+
# Content Preview
|
|
183
|
+
if preview_type == "scalar":
|
|
184
|
+
value = preview_scalar(art)
|
|
185
|
+
st.metric(label="Value", value=value)
|
|
186
|
+
|
|
187
|
+
elif preview_type == "image":
|
|
188
|
+
image_path = preview_image(art)
|
|
189
|
+
if image_path and image_path.exists():
|
|
190
|
+
# [Modified] use_column_width=True -> width="stretch"
|
|
191
|
+
st.image(str(image_path), caption=art.artifactKey, width="stretch")
|
|
192
|
+
else:
|
|
193
|
+
st.warning("Image file missing")
|
|
194
|
+
|
|
195
|
+
elif preview_type == "json_table":
|
|
196
|
+
table_data = preview_json_table(art)
|
|
197
|
+
if table_data:
|
|
198
|
+
# Use static table for cleaner look
|
|
199
|
+
st.table([dict(zip(table_data["columns"], row)) for row in table_data["rows"]])
|
|
200
|
+
else:
|
|
201
|
+
st.warning("Invalid table data")
|
|
202
|
+
|
|
203
|
+
elif preview_type == "json_raw":
|
|
204
|
+
json_content = preview_json_raw(art)
|
|
205
|
+
if json_content:
|
|
206
|
+
st.code(json_content, language="json")
|
|
207
|
+
else:
|
|
208
|
+
st.warning("Empty JSON")
|
|
209
|
+
|
|
210
|
+
elif preview_type == "file":
|
|
211
|
+
path = reader.get_artifact_path(art.artifactKey)
|
|
212
|
+
if path:
|
|
213
|
+
f_col1, f_col2 = st.columns([4, 1])
|
|
214
|
+
with f_col1:
|
|
215
|
+
st.code(str(path), language="text")
|
|
216
|
+
with f_col2:
|
|
217
|
+
if path.exists():
|
|
218
|
+
st.download_button(
|
|
219
|
+
"⬇️ Download",
|
|
220
|
+
data=path.read_bytes(),
|
|
221
|
+
file_name=path.name,
|
|
222
|
+
mime=art.mimeType or "application/octet-stream",
|
|
223
|
+
use_container_width=True # Button still uses old kwarg? No, replaced below if needed in logic, but standard download_button uses use_container_width in modern versions. If your version deprecated it for buttons too, this should be width="stretch". Let's stick to consistent modern API.
|
|
224
|
+
)
|
|
225
|
+
else:
|
|
226
|
+
st.warning("File path resolution failed")
|
|
227
|
+
|
|
228
|
+
else:
|
|
229
|
+
st.info("No preview available for this artifact type.")
|
|
230
|
+
|
|
231
|
+
st.divider()
|
|
232
|
+
|
|
233
|
+
# ==========================================
|
|
234
|
+
# 4. Logs & Debug
|
|
235
|
+
# ==========================================
|
|
236
|
+
st.subheader("Logs & System Info")
|
|
237
|
+
|
|
238
|
+
tab1, tab2, tab3, tab4 = st.tabs(["📜 Algorithm Log", "📤 Stdout", "⚠️ Stderr", "🔍 Manifest JSON"])
|
|
239
|
+
|
|
240
|
+
with tab1:
|
|
241
|
+
log = reader.read_algorithm_log()
|
|
242
|
+
if log:
|
|
243
|
+
st.code(log, language="text")
|
|
244
|
+
else:
|
|
245
|
+
st.caption("No algorithm log available.")
|
|
246
|
+
|
|
247
|
+
with tab2:
|
|
248
|
+
stdout = reader.read_stdout()
|
|
249
|
+
if stdout:
|
|
250
|
+
st.code(stdout, language="text")
|
|
251
|
+
else:
|
|
252
|
+
st.caption("No stdout recorded.")
|
|
253
|
+
|
|
254
|
+
with tab3:
|
|
255
|
+
stderr = reader.read_stderr()
|
|
256
|
+
if stderr:
|
|
257
|
+
st.code(stderr, language="text")
|
|
258
|
+
else:
|
|
259
|
+
st.caption("No stderr recorded.")
|
|
260
|
+
|
|
261
|
+
with tab4:
|
|
262
|
+
if reader.has_manifest():
|
|
263
|
+
manifest = reader.read_manifest()
|
|
264
|
+
st.code(json.dumps(manifest.model_dump(exclude_none=True), indent=2), language="json")
|
|
265
|
+
else:
|
|
266
|
+
st.caption("No manifest file.")
|
|
267
|
+
|
|
268
|
+
st.divider()
|
|
269
|
+
|
|
270
|
+
# ==========================================
|
|
271
|
+
# 5. Danger Zone
|
|
272
|
+
# ==========================================
|
|
273
|
+
with st.expander("🗑️ Danger Zone", expanded=False):
|
|
274
|
+
st.markdown("Deleting a run is irreversible. It will remove all artifacts and logs.")
|
|
275
|
+
|
|
276
|
+
confirm_key = f"confirm_del_run_{run.run_id}"
|
|
277
|
+
|
|
278
|
+
if st.button("Delete This Run", key=f"del_btn_{run.run_id}", type="secondary"):
|
|
279
|
+
st.session_state[confirm_key] = True
|
|
280
|
+
|
|
281
|
+
if st.session_state.get(confirm_key):
|
|
282
|
+
st.warning(f"Are you sure you want to delete Run `{run.run_id}`?")
|
|
283
|
+
d_c1, d_c2 = st.columns([1, 1])
|
|
284
|
+
with d_c1:
|
|
285
|
+
# [Modified] use_container_width -> width="stretch"
|
|
286
|
+
if st.button("Yes, Delete", key=f"yes_del_{run.run_id}", type="primary", width="stretch"):
|
|
287
|
+
try:
|
|
288
|
+
run_manager.delete_run(run.run_id)
|
|
289
|
+
st.success(f"Deleted run {run.run_id}")
|
|
290
|
+
st.session_state.pop(confirm_key, None)
|
|
291
|
+
st.rerun()
|
|
292
|
+
except Exception as e:
|
|
293
|
+
st.error(f"Delete failed: {e}")
|
|
294
|
+
with d_c2:
|
|
295
|
+
# [Modified] use_container_width -> width="stretch"
|
|
296
|
+
if st.button("Cancel", key=f"no_del_{run.run_id}", width="stretch"):
|
|
297
|
+
st.session_state.pop(confirm_key, None)
|
|
298
|
+
st.rerun()
|