flashruntime 0.3.0__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.
- flashml_workloads/__init__.py +7 -0
- flashml_workloads/fedavg_driver.py +569 -0
- flashml_workloads/fedavg_weights.py +223 -0
- flashml_workloads/fedavg_worker.py +166 -0
- flashml_workloads/kmeans_driver.py +134 -0
- flashml_workloads/kmeans_shard.py +69 -0
- flashml_workloads/sgd_trainer.py +127 -0
- flashml_workloads/sharded_kmeans.py +323 -0
- flashml_workloads/sklearn_trial.py +89 -0
- flashruntime/__init__.py +125 -0
- flashruntime/artifacts/__init__.py +25 -0
- flashruntime/artifacts/store.py +228 -0
- flashruntime/backends/__init__.py +26 -0
- flashruntime/backends/base.py +63 -0
- flashruntime/backends/kuberay.py +465 -0
- flashruntime/checkpoint/__init__.py +20 -0
- flashruntime/checkpoint/catalog.py +198 -0
- flashruntime/checkpoint/local.py +109 -0
- flashruntime/checkpoint/store.py +86 -0
- flashruntime/integrations/__init__.py +5 -0
- flashruntime/integrations/huggingface.py +59 -0
- flashruntime/integrations/pytorch.py +52 -0
- flashruntime/integrations/sklearn.py +42 -0
- flashruntime/launchers/__init__.py +130 -0
- flashruntime/launchers/local.py +126 -0
- flashruntime/leases/__init__.py +27 -0
- flashruntime/leases/manager.py +365 -0
- flashruntime/leases/sqlite_store.py +169 -0
- flashruntime/leases/store.py +103 -0
- flashruntime/monitor/__init__.py +7 -0
- flashruntime/monitor/sampler.py +232 -0
- flashruntime/planner/__init__.py +56 -0
- flashruntime/planner/candidates.py +597 -0
- flashruntime/planner/catalog.py +129 -0
- flashruntime/planner/comm.py +95 -0
- flashruntime/planner/explain.py +109 -0
- flashruntime/planner/memory.py +166 -0
- flashruntime/planner/resolve.py +120 -0
- flashruntime/planner/selector.py +169 -0
- flashruntime/planner/timecost.py +81 -0
- flashruntime/profiling/__init__.py +113 -0
- flashruntime/protocol/__init__.py +18 -0
- flashruntime/protocol/plan_v1alpha1.py +320 -0
- flashruntime/protocol/v1alpha1.py +465 -0
- flashruntime/providers/__init__.py +138 -0
- flashruntime/py.typed +0 -0
- flashruntime/recipes/__init__.py +135 -0
- flashruntime/recipes/command.py +166 -0
- flashruntime/recovery/__init__.py +21 -0
- flashruntime/recovery/policy.py +170 -0
- flashruntime/recovery/signals.py +135 -0
- flashruntime/recovery/taxonomy.py +91 -0
- flashruntime/scheduler/__init__.py +170 -0
- flashruntime/sdk.py +402 -0
- flashruntime/service/__init__.py +3 -0
- flashruntime/service/app.py +391 -0
- flashruntime/service/auth.py +180 -0
- flashruntime/service/checkpoints.py +90 -0
- flashruntime/service/cli.py +167 -0
- flashruntime/service/dashboard.py +193 -0
- flashruntime/service/ledger.py +101 -0
- flashruntime/service/modea.py +821 -0
- flashruntime/strategies/__init__.py +156 -0
- flashruntime/strategies/command.py +56 -0
- flashruntime/torch/__init__.py +274 -0
- flashruntime/viewer/__init__.py +20 -0
- flashruntime/viewer/_docs/benchmarks.html +771 -0
- flashruntime/viewer/_docs/concepts/architecture.html +302 -0
- flashruntime/viewer/_docs/get-started.html +263 -0
- flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
- flashruntime/viewer/_docs/guides/huggingface.html +223 -0
- flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
- flashruntime/viewer/_docs/guides/pytorch.html +313 -0
- flashruntime/viewer/_docs/guides/sklearn.html +232 -0
- flashruntime/viewer/_docs/index.html +251 -0
- flashruntime/viewer/_docs/reference/cli.html +254 -0
- flashruntime/viewer/_docs/reference/integrations.html +240 -0
- flashruntime/viewer/_docs/reference/sdk.html +341 -0
- flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
- flashruntime/viewer/_docs/search-index.json +1 -0
- flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
- flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
- flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
- flashruntime/viewer/flowmap.py +307 -0
- flashruntime/viewer/page.py +594 -0
- flashruntime/viewer/server.py +134 -0
- flashruntime/viewer/state.py +250 -0
- flashruntime/workloads/__init__.py +6 -0
- flashruntime/workloads/command.py +127 -0
- flashruntime-0.3.0.dist-info/METADATA +365 -0
- flashruntime-0.3.0.dist-info/RECORD +95 -0
- flashruntime-0.3.0.dist-info/WHEEL +5 -0
- flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
- flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
- flashruntime-0.3.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Tutorial: parallel scikit-learn sweeps — flashruntime docs</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; }
|
|
9
|
+
* { box-sizing: border-box; margin: 0; }
|
|
10
|
+
body { font: 14px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #0d1117; color: #c9d1d9; }
|
|
11
|
+
a { color: oklch(0.80 0.16 200); text-decoration: none; }
|
|
12
|
+
a:hover { text-decoration: underline; }
|
|
13
|
+
.layout { display: flex; align-items: flex-start; min-height: 100vh; }
|
|
14
|
+
|
|
15
|
+
/* sidebar — PyTorch-docs-like: sections from _nav.yml, current page active */
|
|
16
|
+
.sidebar { flex: 0 0 264px; position: sticky; top: 0; height: 100vh; overflow-y: auto;
|
|
17
|
+
border-right: 1px solid #21262d; background: #0d1117; padding: 20px 16px; }
|
|
18
|
+
.brand { color: #e6edf3; font-size: 15px; letter-spacing: .04em; }
|
|
19
|
+
.brand small { display: block; margin-top: 2px; color: #8b949e; font-size: 10px;
|
|
20
|
+
text-transform: uppercase; letter-spacing: .14em; }
|
|
21
|
+
.search { position: relative; margin: 16px 0; }
|
|
22
|
+
#q { width: 100%; background: #010409; border: 1px solid #21262d; border-radius: 6px;
|
|
23
|
+
color: #c9d1d9; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 7px 9px; }
|
|
24
|
+
#q:focus { outline: 1px solid oklch(0.80 0.16 200); }
|
|
25
|
+
#results { position: absolute; left: 0; right: 0; top: 112%; z-index: 5; display: none;
|
|
26
|
+
background: #161b22; border: 1px solid #21262d; border-radius: 6px; overflow: hidden; }
|
|
27
|
+
#results.open { display: block; }
|
|
28
|
+
#results a { display: block; padding: 7px 9px; border-bottom: 1px solid #21262d; color: #c9d1d9; }
|
|
29
|
+
#results a:last-child { border-bottom: 0; }
|
|
30
|
+
#results a:hover { background: #010409; text-decoration: none; }
|
|
31
|
+
#results a b { color: #e6edf3; font-weight: 600; }
|
|
32
|
+
#results a span { display: block; margin-top: 2px; color: #8b949e; font-size: 11px; }
|
|
33
|
+
#results .nohit { padding: 7px 9px; color: #8b949e; }
|
|
34
|
+
.nav-section { margin: 16px 0 6px; color: #8b949e; font-size: 10px; font-weight: 600;
|
|
35
|
+
text-transform: uppercase; letter-spacing: .14em; }
|
|
36
|
+
.nav-link { display: block; padding: 4px 8px; border-radius: 5px; color: #c9d1d9; font-size: 13px; }
|
|
37
|
+
.nav-link:hover { background: #161b22; text-decoration: none; }
|
|
38
|
+
.nav-link.active { background: #161b22; color: #e6edf3; box-shadow: inset 2px 0 0 oklch(0.80 0.16 200); }
|
|
39
|
+
|
|
40
|
+
/* content */
|
|
41
|
+
.content { flex: 1 1 auto; max-width: 840px; min-width: 0; padding: 34px 40px 80px; }
|
|
42
|
+
.content h1 { color: #e6edf3; font-size: 26px; margin: 0 0 16px; }
|
|
43
|
+
.content h2 { color: #e6edf3; font-size: 18px; margin: 30px 0 10px;
|
|
44
|
+
padding-top: 10px; border-top: 1px solid #21262d; }
|
|
45
|
+
.content h3 { color: #e6edf3; font-size: 15px; margin: 22px 0 8px; }
|
|
46
|
+
.content p, .content li { color: #c9d1d9; }
|
|
47
|
+
.content ul, .content ol { padding-left: 22px; margin: 10px 0; }
|
|
48
|
+
.content li { margin: 4px 0; }
|
|
49
|
+
.content blockquote { margin: 14px 0; padding: 2px 14px; border-left: 3px solid oklch(0.80 0.18 60);
|
|
50
|
+
background: #161b22; border-radius: 0 6px 6px 0; color: #8b949e; }
|
|
51
|
+
.content :not(pre) > code { background: #010409; border: 1px solid #21262d;
|
|
52
|
+
border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
|
|
53
|
+
.content table { border-collapse: collapse; margin: 14px 0; display: block; overflow-x: auto; }
|
|
54
|
+
.content th, .content td { border: 1px solid #21262d; padding: 6px 10px; text-align: left; }
|
|
55
|
+
.content th { color: #e6edf3; background: #161b22; }
|
|
56
|
+
|
|
57
|
+
/* code blocks + copy button */
|
|
58
|
+
.codeblock { position: relative; margin: 14px 0; }
|
|
59
|
+
.codeblock pre { background: #010409; border: 1px solid #21262d; border-radius: 8px;
|
|
60
|
+
padding: 14px 16px; overflow-x: auto; }
|
|
61
|
+
.codeblock pre code { padding: 0; border: 0; background: none; font-size: 12.5px; color: #c9d1d9; }
|
|
62
|
+
.copybtn { position: absolute; top: 8px; right: 8px; padding: 3px 8px; cursor: pointer;
|
|
63
|
+
background: #161b22; border: 1px solid #21262d; border-radius: 5px;
|
|
64
|
+
color: #8b949e; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
65
|
+
.copybtn:hover { color: #e6edf3; border-color: oklch(0.80 0.16 200); }
|
|
66
|
+
|
|
67
|
+
@media (max-width: 800px) {
|
|
68
|
+
.layout { flex-direction: column; }
|
|
69
|
+
.sidebar { position: static; height: auto; width: 100%; flex-basis: auto;
|
|
70
|
+
border-right: 0; border-bottom: 1px solid #21262d; }
|
|
71
|
+
.content { padding: 24px 18px 60px; }
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<div class="layout">
|
|
77
|
+
<aside class="sidebar">
|
|
78
|
+
<a class="brand" href="../index.html">flashruntime<small>documentation</small></a>
|
|
79
|
+
<div class="search">
|
|
80
|
+
<input id="q" type="search" placeholder="Search docs (press /)" autocomplete="off" spellcheck="false">
|
|
81
|
+
<div id="results"></div>
|
|
82
|
+
</div>
|
|
83
|
+
<nav><div class="nav-section">Getting started</div>
|
|
84
|
+
<a class="nav-link" href="../index.html">FlashRuntime</a>
|
|
85
|
+
<a class="nav-link" href="../get-started.html">Get started</a>
|
|
86
|
+
<div class="nav-section">Tutorials</div>
|
|
87
|
+
<a class="nav-link" href="convnet.html">Tutorial: make a ConvNet fault-tolerant</a>
|
|
88
|
+
<a class="nav-link active" href="sklearn-sweeps.html">Tutorial: parallel scikit-learn sweeps</a>
|
|
89
|
+
<a class="nav-link" href="fault-tolerance.html">Tutorial: automatic recovery, explained</a>
|
|
90
|
+
<div class="nav-section">Guides</div>
|
|
91
|
+
<a class="nav-link" href="../guides/pytorch.html">PyTorch guide</a>
|
|
92
|
+
<a class="nav-link" href="../guides/sklearn.html">scikit-learn guide</a>
|
|
93
|
+
<a class="nav-link" href="../guides/huggingface.html">Hugging Face guide</a>
|
|
94
|
+
<a class="nav-link" href="../guides/jobspec-and-isolation.html">JobSpec & isolation guide</a>
|
|
95
|
+
<a class="nav-link" href="../guides/federated-averaging.html">Federated averaging</a>
|
|
96
|
+
<div class="nav-section">Concepts</div>
|
|
97
|
+
<a class="nav-link" href="../concepts/architecture.html">Architecture</a>
|
|
98
|
+
<div class="nav-section">Reference</div>
|
|
99
|
+
<a class="nav-link" href="../reference/sdk.html">Reference: SDK (`flashruntime`)</a>
|
|
100
|
+
<a class="nav-link" href="../reference/integrations.html">Reference: integrations (`flashruntime.integrations`)</a>
|
|
101
|
+
<a class="nav-link" href="../reference/torch-helper.html">Reference: torch helper (`flashruntime.torch`)</a>
|
|
102
|
+
<a class="nav-link" href="../reference/cli.html">Reference: CLI (`flashruntime`)</a>
|
|
103
|
+
<div class="nav-section">Benchmarks</div>
|
|
104
|
+
<a class="nav-link" href="../benchmarks.html">Benchmarks</a></nav>
|
|
105
|
+
</aside>
|
|
106
|
+
<main class="content"><h1 id="tutorial-parallel-scikit-learn-sweeps">Tutorial: parallel scikit-learn sweeps</h1>
|
|
107
|
+
<p>scikit-learn work is <em>embarrassingly parallel across runs</em> — a grid of
|
|
108
|
+
independent fits, never a single <code>.fit()</code> you split internally. FlashRuntime
|
|
109
|
+
fans a grid out into one independent task per trial, runs them, and ranks the
|
|
110
|
+
results, while your script stays plain sklearn with no FlashRuntime import.</p>
|
|
111
|
+
<p>This tutorial builds a sweep from an ordinary script. You need
|
|
112
|
+
<code>pip install "flashruntime[sklearn]"</code> (numpy + scikit-learn) — see
|
|
113
|
+
<a href="../get-started.html">Get started</a>.</p>
|
|
114
|
+
<hr />
|
|
115
|
+
<h2 id="1-a-plain-sklearn-script-flags-in-metricsjson-out">1. A plain sklearn script (flags in, <code>metrics.json</code> out)</h2>
|
|
116
|
+
<p>The only contract FlashRuntime asks: read hyperparameters from CLI flags, write
|
|
117
|
+
a flat <code>metrics.json</code>. No FlashRuntime import anywhere.</p>
|
|
118
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-python">import argparse
|
|
119
|
+
import json
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main():
|
|
123
|
+
parser = argparse.ArgumentParser()
|
|
124
|
+
parser.add_argument("--model", default="logreg")
|
|
125
|
+
parser.add_argument("--C", type=float, default=1.0)
|
|
126
|
+
parser.add_argument("--n_estimators", type=int, default=50)
|
|
127
|
+
args = parser.parse_args()
|
|
128
|
+
|
|
129
|
+
from sklearn.datasets import make_classification
|
|
130
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
131
|
+
from sklearn.linear_model import LogisticRegression
|
|
132
|
+
from sklearn.model_selection import cross_val_score
|
|
133
|
+
|
|
134
|
+
X, y = make_classification(n_samples=600, n_features=12, random_state=0)
|
|
135
|
+
if args.model == "logreg":
|
|
136
|
+
estimator = LogisticRegression(C=args.C, max_iter=500, random_state=0)
|
|
137
|
+
elif args.model == "rf":
|
|
138
|
+
estimator = RandomForestClassifier(n_estimators=args.n_estimators, random_state=0)
|
|
139
|
+
else:
|
|
140
|
+
raise SystemExit(f"unknown model {args.model!r} (logreg|rf)")
|
|
141
|
+
|
|
142
|
+
scores = cross_val_score(estimator, X, y, cv=3)
|
|
143
|
+
metrics = {
|
|
144
|
+
"model": args.model,
|
|
145
|
+
"C": args.C,
|
|
146
|
+
"n_estimators": args.n_estimators,
|
|
147
|
+
"accuracy_mean": round(float(scores.mean()), 4),
|
|
148
|
+
}
|
|
149
|
+
with open("metrics.json", "w") as f:
|
|
150
|
+
json.dump(metrics, f, indent=2)
|
|
151
|
+
print(metrics)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|
|
156
|
+
</code></pre></div>
|
|
157
|
+
<p>Run one trial by hand to confirm it works:</p>
|
|
158
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-bash">python train.py --model rf --n_estimators 100
|
|
159
|
+
</code></pre></div>
|
|
160
|
+
<hr />
|
|
161
|
+
<h2 id="2-fan-a-grid-out-with-fr_sklearnhpo">2. Fan a grid out with <code>fr_sklearn.hpo(...)</code></h2>
|
|
162
|
+
<p>The <code>integrations.sklearn</code> adapter builds the workload from that script. Give
|
|
163
|
+
it a grid; it expands the Cartesian product into one task per trial:</p>
|
|
164
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-python">import flashruntime as flash
|
|
165
|
+
from flashruntime.integrations import sklearn as fr_sklearn
|
|
166
|
+
|
|
167
|
+
run = flash.submit(fr_sklearn.hpo(
|
|
168
|
+
"train.py",
|
|
169
|
+
{"model": ["logreg", "rf"], "C": [0.1, 1.0], "n_estimators": [50]},
|
|
170
|
+
source=".",
|
|
171
|
+
))
|
|
172
|
+
print(f"state={run.state.value} trials={len(run.trials)}")
|
|
173
|
+
print("best:", run.best_trial())
|
|
174
|
+
</code></pre></div>
|
|
175
|
+
<p><code>{"model": ["logreg", "rf"], "C": [0.1, 1.0], "n_estimators": [50]}</code> expands to
|
|
176
|
+
<code>2 × 2 × 1 = 4</code> trials. Each <code>{placeholder}</code> in the built command is filled
|
|
177
|
+
from the trial's params, so one task receives <code>--model rf --C 1.0
|
|
178
|
+
--n_estimators 50</code> and so on. Every trial's <code>metrics.json</code> is collected and
|
|
179
|
+
recorded on <code>run.trials</code>, with its params merged in.</p>
|
|
180
|
+
<hr />
|
|
181
|
+
<h2 id="3-read-the-winner">3. Read the winner</h2>
|
|
182
|
+
<p>Because <code>hpo</code> (via <code>sweep</code>) sets <code>outputs.primary_metric="accuracy_mean"</code>,
|
|
183
|
+
<code>run.best_trial()</code> needs no arguments — it returns the trial with the highest
|
|
184
|
+
<code>accuracy_mean</code>:</p>
|
|
185
|
+
<div class="codeblock"><button class="copybtn" type="button" aria-label="Copy code to clipboard">Copy</button><pre><code class="language-python">best = run.best_trial() # ranks by accuracy_mean (maximize=True)
|
|
186
|
+
worst = run.best_trial(maximize=False) # or flip it
|
|
187
|
+
by_other = run.best_trial(metric="C") # or rank by any reported key
|
|
188
|
+
</code></pre></div>
|
|
189
|
+
<p><code>best_trial(metric=None, maximize=None)</code> falls back to the <code>OutputSpec</code>
|
|
190
|
+
defaults the adapter set; pass <code>metric=</code> / <code>maximize=</code> to override. It returns
|
|
191
|
+
<code>None</code> if no trial reported the metric.</p>
|
|
192
|
+
<hr />
|
|
193
|
+
<h2 id="how-the-fan-out-stays-correct">How the fan-out stays correct</h2>
|
|
194
|
+
<ul>
|
|
195
|
+
<li><strong>Sequential and isolated.</strong> <code>flash.submit()</code> runs one trial at a time and
|
|
196
|
+
copies each trial's <code>metrics.json</code> out <strong>before</strong> the next trial can
|
|
197
|
+
overwrite it — so a trial's outputs are always its own.</li>
|
|
198
|
+
<li><strong>Independent checkpoint trees.</strong> Each trial gets its own job-scoped
|
|
199
|
+
checkpoint tree, so trials never cross-contaminate. (This matters for
|
|
200
|
+
checkpointed workloads; a pure sklearn fit has none.)</li>
|
|
201
|
+
<li><strong>Two API shapes.</strong> <code>hpo(script, grid, **kwargs)</code> is grid sugar over
|
|
202
|
+
<code>sweep(script, task_params, *, source=".", metric="accuracy_mean",
|
|
203
|
+
maximize=True, python="python")</code>. Use <code>sweep</code> directly to pass an explicit
|
|
204
|
+
list of param dicts (e.g. a hand-picked, non-Cartesian set).</li>
|
|
205
|
+
</ul>
|
|
206
|
+
<p>Add a restart budget the same way as any run: <code>flash.submit(..., max_restarts=1)</code>
|
|
207
|
+
retries a <em>transient</em> trial failure and fails fast on a deterministic one — see
|
|
208
|
+
the <a href="fault-tolerance.html">fault-tolerance tutorial</a>.</p>
|
|
209
|
+
<hr />
|
|
210
|
+
<h2 id="where-to-go-next">Where to go next</h2>
|
|
211
|
+
<ul>
|
|
212
|
+
<li><strong><a href="../guides/sklearn.html">scikit-learn guide</a></strong> — the adapter reference and
|
|
213
|
+
the "distribute across runs, never inside <code>.fit()</code>" rule.</li>
|
|
214
|
+
<li><strong><a href="convnet.html">ConvNet tutorial</a></strong> — the PyTorch DDP + checkpoint-resume
|
|
215
|
+
story.</li>
|
|
216
|
+
<li><strong><a href="../reference/sdk.html">SDK reference</a></strong> — every <code>Run</code> attribute and
|
|
217
|
+
<code>submit()</code> argument.</li>
|
|
218
|
+
</ul></main>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<script>
|
|
222
|
+
// ---- client-side search: fetch the builder's index, filter as you type -----
|
|
223
|
+
// (<=60 lines, vanilla JS, no external anything — the index is a sibling file.)
|
|
224
|
+
let INDEX = [];
|
|
225
|
+
// Path from THIS page back to the site root. The index stores root-relative
|
|
226
|
+
// URLs (e.g. "tutorials/deep.html"); we fetch it and build result links
|
|
227
|
+
// against ROOT so a page in a subdir resolves them too (see build_docs.py).
|
|
228
|
+
const ROOT = "../";
|
|
229
|
+
const q = document.getElementById("q");
|
|
230
|
+
const results = document.getElementById("results");
|
|
231
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
|
232
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
233
|
+
fetch(ROOT + "search-index.json").then((r) => r.json()).then((d) => { INDEX = d; }).catch(() => {});
|
|
234
|
+
|
|
235
|
+
// A short context window around the first match, so a hit shows WHY it matched.
|
|
236
|
+
function snippet(text, needle) {
|
|
237
|
+
const i = text.toLowerCase().indexOf(needle);
|
|
238
|
+
if (i < 0) return "";
|
|
239
|
+
const start = Math.max(0, i - 32);
|
|
240
|
+
return (start > 0 ? "…" : "") + text.slice(start, i + needle.length + 44).trim() + "…";
|
|
241
|
+
}
|
|
242
|
+
function runSearch() {
|
|
243
|
+
const needle = q.value.trim().toLowerCase();
|
|
244
|
+
if (!needle) { results.className = ""; results.innerHTML = ""; return; }
|
|
245
|
+
const hits = INDEX.map((p) => {
|
|
246
|
+
const inTitle = p.title.toLowerCase().includes(needle);
|
|
247
|
+
const inText = p.text.toLowerCase().includes(needle);
|
|
248
|
+
if (!inTitle && !inText) return null;
|
|
249
|
+
return { url: p.url, title: p.title, snip: inText ? snippet(p.text, needle) : "" };
|
|
250
|
+
}).filter(Boolean).slice(0, 20);
|
|
251
|
+
results.className = "open";
|
|
252
|
+
results.innerHTML = hits.length
|
|
253
|
+
? hits.map((h) => '<a href="' + ROOT + h.url + '"><b>' + esc(h.title) + "</b>" +
|
|
254
|
+
(h.snip ? "<span>" + esc(h.snip) + "</span>" : "") + "</a>").join("")
|
|
255
|
+
: '<div class="nohit">no matches</div>';
|
|
256
|
+
}
|
|
257
|
+
q.addEventListener("input", runSearch);
|
|
258
|
+
q.addEventListener("focus", runSearch);
|
|
259
|
+
document.addEventListener("click", (e) => {
|
|
260
|
+
if (!e.target.closest(".search")) { results.className = ""; } // dismiss on outside click
|
|
261
|
+
});
|
|
262
|
+
document.addEventListener("keydown", (e) => { // "/" focuses search, like PyTorch docs
|
|
263
|
+
if (e.key === "/" && document.activeElement !== q) { e.preventDefault(); q.focus(); }
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ---- copy buttons: read the <pre>'s own text into the clipboard ------------
|
|
267
|
+
document.querySelectorAll(".copybtn").forEach((btn) => {
|
|
268
|
+
btn.addEventListener("click", () => {
|
|
269
|
+
const code = btn.parentElement.querySelector("pre").innerText;
|
|
270
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
271
|
+
const was = btn.textContent; btn.textContent = "Copied";
|
|
272
|
+
setTimeout(() => { btn.textContent = was; }, 1200);
|
|
273
|
+
}).catch(() => {});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
</script>
|
|
277
|
+
</body>
|
|
278
|
+
</html>
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""The flow-map + KPI strip component, shared between the run viewer page
|
|
2
|
+
(`viewer/page.py`) and — in a later phase — the coordinator dashboard
|
|
3
|
+
(`service/dashboard.py`). Divergence between the two surfaces is a bug, so
|
|
4
|
+
the CSS and JS live here once, as strings the host page embeds inline.
|
|
5
|
+
|
|
6
|
+
Contract with the host page:
|
|
7
|
+
* CSS colors are `%%token%%` placeholders resolved by the host's render()
|
|
8
|
+
from `viewer.page.TOKENS` (the single source of truth).
|
|
9
|
+
* The JS expects a global `T` (the injected token object) and three
|
|
10
|
+
elements: `#kpis`, `#flowmap`, `#detail`.
|
|
11
|
+
* The host polls its own data and calls `renderKpiTiles(tiles)`,
|
|
12
|
+
`renderFlowmap(snapshot)`, `renderDetail(snapshot)` on each tick. The
|
|
13
|
+
component never fetches — data acquisition is the host's business.
|
|
14
|
+
|
|
15
|
+
The map draws machine → workers (attempts) → ranks as DOM nodes (free click
|
|
16
|
+
targets + text layout) with one SVG layer behind them for the edges. It is
|
|
17
|
+
rebuilt idempotently from each snapshot; `fmSelected` (the clicked node)
|
|
18
|
+
survives rebuilds and feeds the slide-in detail panel with live values.
|
|
19
|
+
|
|
20
|
+
Single-machine today, on purpose: the machine column renders exactly one
|
|
21
|
+
node — the local viewer's reality, since `flash.submit(watch=True)` only
|
|
22
|
+
ever observes the machine it is running on. Growing that to N machines for
|
|
23
|
+
the coordinator dashboard (phase 3) is a change to THIS component — extend
|
|
24
|
+
the machine column here so both surfaces gain it together, never fork a
|
|
25
|
+
per-host copy of the flow map. Before that adoption lands, the coordinator
|
|
26
|
+
dashboard also needs two host-page prerequisites it does not have yet: a
|
|
27
|
+
`T` token global (the injected color-token object this JS reads, see
|
|
28
|
+
`viewer/page.py`'s `%%tokens_json%%`) and a `%%token%%` substitution pass
|
|
29
|
+
over its CSS — `service/dashboard.py`'s current `_recolor` hex pipeline is a
|
|
30
|
+
different mechanism and must be reworked onto the same placeholder scheme
|
|
31
|
+
before it can host this component.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
FLOWMAP_CSS = r"""
|
|
37
|
+
/* KPI strip -------------------------------------------------------------- */
|
|
38
|
+
#kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; }
|
|
39
|
+
.kpi { background: %%panel%%; border: 1px solid %%border%%; border-radius: 8px; padding: 8px 12px; }
|
|
40
|
+
.kpi-label { color: %%muted%%; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
|
|
41
|
+
.kpi-value { color: %%text_bright%%; font-size: 18px; font-weight: 600; margin-top: 2px; }
|
|
42
|
+
.kpi-hint { color: %%muted%%; font-size: 10px; margin-top: 2px; }
|
|
43
|
+
|
|
44
|
+
/* flow map ---------------------------------------------------------------- */
|
|
45
|
+
#flowmap { position: relative; display: flex; gap: 48px; padding: 14px;
|
|
46
|
+
min-height: 180px; background: %%bg_inset%%; border-radius: 6px;
|
|
47
|
+
overflow-x: auto; }
|
|
48
|
+
#fm-edges { position: absolute; inset: 0; pointer-events: none; }
|
|
49
|
+
.fm-col { display: flex; flex-direction: column; gap: 10px; justify-content: center;
|
|
50
|
+
position: relative; z-index: 1; min-width: 180px; }
|
|
51
|
+
.fm-node { background: %%panel%%; border: 1px solid %%border%%; border-radius: 8px;
|
|
52
|
+
padding: 8px 10px; cursor: pointer; }
|
|
53
|
+
.fm-node:hover { filter: brightness(1.25); }
|
|
54
|
+
.fm-selected { outline: 2px solid %%running%%; outline-offset: 1px; }
|
|
55
|
+
.fm-machine { border-color: %%running%%; }
|
|
56
|
+
.fm-done { opacity: .55; }
|
|
57
|
+
.fm-live .fm-title { animation: fm-pulse 2s ease-in-out infinite; }
|
|
58
|
+
@keyframes fm-pulse { 50% { opacity: .55; } }
|
|
59
|
+
.fm-title { color: %%text_bright%%; }
|
|
60
|
+
.fm-sub { color: %%muted%%; font-size: 11px; margin-top: 2px; }
|
|
61
|
+
.fm-badge { color: %%warn%%; border: 1px solid %%warn%%; border-radius: 4px;
|
|
62
|
+
font-size: 10px; padding: 0 4px; }
|
|
63
|
+
.fm-more { color: %%muted%%; font-size: 11px; }
|
|
64
|
+
|
|
65
|
+
/* detail panel (slides in over the map's right edge) ---------------------- */
|
|
66
|
+
#detail { position: absolute; top: 8px; right: 8px; bottom: 8px; width: min(360px, 60%);
|
|
67
|
+
background: %%panel%%; border: 1px solid %%running%%; border-radius: 8px;
|
|
68
|
+
padding: 14px; overflow-y: auto; z-index: 2; }
|
|
69
|
+
#detail h3 { color: %%text_bright%%; font-size: 13px; margin-bottom: 10px;
|
|
70
|
+
text-transform: none; letter-spacing: 0; }
|
|
71
|
+
.fm-kv { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0;
|
|
72
|
+
border-bottom: 1px solid %%border%%; }
|
|
73
|
+
.fm-kv span { color: %%muted%%; }
|
|
74
|
+
.fm-kv b { color: %%text%%; font-weight: 600; text-align: right; word-break: break-all; }
|
|
75
|
+
.fm-close { position: absolute; top: 8px; right: 8px; background: none;
|
|
76
|
+
border: 1px solid %%border%%; color: %%muted%%; border-radius: 6px;
|
|
77
|
+
cursor: pointer; padding: 2px 8px; font: inherit; }
|
|
78
|
+
.fm-close:hover { color: %%text_bright%%; }
|
|
79
|
+
.fm-log { margin-top: 10px; padding: 8px; background: %%bg_inset%%; border-radius: 6px;
|
|
80
|
+
white-space: pre-wrap; word-break: break-word; color: %%text%%;
|
|
81
|
+
font-size: 11px; max-height: 200px; overflow-y: auto; }
|
|
82
|
+
.fm-hint { margin-top: 10px; color: %%warn%%; font-size: 11px; }
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
FLOWMAP_JS = r"""
|
|
86
|
+
// ==== flow map + KPI components (viewer/flowmap.py — shared surface) =======
|
|
87
|
+
// Requires: global `T` (color tokens) and elements #kpis, #flowmap, #detail.
|
|
88
|
+
// The host page owns polling; these functions only render a given snapshot.
|
|
89
|
+
|
|
90
|
+
const fmEsc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) =>
|
|
91
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
92
|
+
|
|
93
|
+
// Lifecycle state → accent. The shared vocabulary both surfaces use.
|
|
94
|
+
function fmStateColor(s) {
|
|
95
|
+
switch (s) {
|
|
96
|
+
case "RUNNING": return T.running; // cyan
|
|
97
|
+
case "LEASED": case "RECOVERING": return T.warn; // amber
|
|
98
|
+
case "SUCCEEDED": case "COMPLETED": return T.ok; // green
|
|
99
|
+
case "FAILED": return T.fail; // red
|
|
100
|
+
default: return T.muted; // PENDING / CANCELLED
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fmFmtBytes(n) {
|
|
105
|
+
if (typeof n !== "number" || !isFinite(n)) return "—";
|
|
106
|
+
const gb = n / (1024 ** 3);
|
|
107
|
+
return gb >= 1 ? gb.toFixed(1) + " GB" : Math.round(n / (1024 ** 2)) + " MB";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- KPI strip -------------------------------------------------------------
|
|
111
|
+
// tiles: [{label, value, hint?, color?}]. A tile with no data source shows
|
|
112
|
+
// "—" (the host builds tiles that way) — the strip never invents a number.
|
|
113
|
+
function renderKpiTiles(tiles) {
|
|
114
|
+
const el = document.getElementById("kpis");
|
|
115
|
+
el.innerHTML = tiles.map((t) =>
|
|
116
|
+
'<div class="kpi"><div class="kpi-label">' + fmEsc(t.label) + "</div>" +
|
|
117
|
+
'<div class="kpi-value"' + (t.color ? ' style="color:' + fmEsc(t.color) + '"' : "") + ">" +
|
|
118
|
+
fmEsc(t.value) + "</div>" +
|
|
119
|
+
(t.hint ? '<div class="kpi-hint">' + fmEsc(t.hint) + "</div>" : "") +
|
|
120
|
+
"</div>"
|
|
121
|
+
).join("");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- flow map ---------------------------------------------------------------
|
|
125
|
+
// Selection survives re-renders: nodes are keyed {kind, id} and the map is
|
|
126
|
+
// rebuilt from scratch on every snapshot (idempotent — no DOM bookkeeping).
|
|
127
|
+
let fmSelected = null; // {kind: "machine"|"worker"|"rank", id: string} | null
|
|
128
|
+
|
|
129
|
+
// newest telemetry sample carried by one attempt row (or null)
|
|
130
|
+
function fmLastSample(a) {
|
|
131
|
+
const tel = a.telemetry || [];
|
|
132
|
+
return tel.length ? tel[tel.length - 1] : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderFlowmap(s) {
|
|
136
|
+
const el = document.getElementById("flowmap");
|
|
137
|
+
const attempts = s.attempts || [];
|
|
138
|
+
const machine = (s.monitor && s.monitor.machine) || null;
|
|
139
|
+
|
|
140
|
+
// machine column — one node (multi-machine arrives with the coordinator)
|
|
141
|
+
const mSel = fmSelected && fmSelected.kind === "machine";
|
|
142
|
+
const host = machine ? machine.hostname : "localhost";
|
|
143
|
+
const mSub = machine
|
|
144
|
+
? (machine.cpu_count || "?") + " cores" +
|
|
145
|
+
(machine.cpu_percent != null ? " · cpu " + Math.round(machine.cpu_percent) + "%" : "") +
|
|
146
|
+
(machine.gpus && machine.gpus.length ? " · " + machine.gpus.length + " gpu" : "")
|
|
147
|
+
: "no telemetry yet";
|
|
148
|
+
let html = '<div class="fm-col">' +
|
|
149
|
+
'<div class="fm-node fm-machine' + (mSel ? " fm-selected" : "") +
|
|
150
|
+
'" data-kind="machine" data-id="machine">' +
|
|
151
|
+
'<div class="fm-title">▣ ' + fmEsc(host) + "</div>" +
|
|
152
|
+
'<div class="fm-sub">' + fmEsc(mSub) + "</div></div></div>";
|
|
153
|
+
|
|
154
|
+
// worker column — one node per attempt, newest kept (cap so a 100-trial
|
|
155
|
+
// sweep stays readable; the count of hidden ones is stated, not silent)
|
|
156
|
+
const shown = attempts.slice(-16);
|
|
157
|
+
html += '<div class="fm-col">' + (attempts.length > shown.length
|
|
158
|
+
? '<div class="fm-more">… ' + (attempts.length - shown.length) + " earlier attempts</div>" : "");
|
|
159
|
+
for (const a of shown) {
|
|
160
|
+
const sel = fmSelected && fmSelected.kind === "worker" && fmSelected.id === a.attempt_id;
|
|
161
|
+
const color = fmStateColor(a.state);
|
|
162
|
+
const respawn = /-r\d+$/.test(a.attempt_id || "");
|
|
163
|
+
const mark = a.state === "RUNNING" ? "●" : a.state === "SUCCEEDED" ? "✓"
|
|
164
|
+
: a.state === "FAILED" ? "✗" : "○";
|
|
165
|
+
html += '<div class="fm-node fm-worker' + (sel ? " fm-selected" : "") +
|
|
166
|
+
(a.state === "RUNNING" ? " fm-live" : " fm-done") + '"' +
|
|
167
|
+
' data-kind="worker" data-id="' + fmEsc(a.attempt_id) + '" style="border-color:' + color + '">' +
|
|
168
|
+
'<div class="fm-title" style="color:' + color + '">' + mark + " " + fmEsc(a.attempt_id) +
|
|
169
|
+
(respawn ? ' <span class="fm-badge">⟳ respawn</span>' : "") + "</div>" +
|
|
170
|
+
'<div class="fm-sub">pid ' + fmEsc(a.pid) + " · " + fmEsc(a.state) + "</div></div>";
|
|
171
|
+
}
|
|
172
|
+
html += "</div>";
|
|
173
|
+
|
|
174
|
+
// rank column — heartbeat ranks per attempt; for a live uninstrumented
|
|
175
|
+
// attempt, fall back to its process tree's children (pid-only nodes)
|
|
176
|
+
html += '<div class="fm-col">';
|
|
177
|
+
for (const a of shown) {
|
|
178
|
+
const ranks = a.ranks || [];
|
|
179
|
+
const sample = fmLastSample(a);
|
|
180
|
+
const procs = (sample && sample.processes) || [];
|
|
181
|
+
const byPid = {};
|
|
182
|
+
for (const p of procs) byPid[p.pid] = p;
|
|
183
|
+
if (ranks.length) {
|
|
184
|
+
for (const r of ranks) {
|
|
185
|
+
const rid = a.attempt_id + "/" + r.rank;
|
|
186
|
+
const sel = fmSelected && fmSelected.kind === "rank" && fmSelected.id === rid;
|
|
187
|
+
const proc = byPid[r.pid];
|
|
188
|
+
html += '<div class="fm-node fm-rank' + (sel ? " fm-selected" : "") +
|
|
189
|
+
(a.state === "RUNNING" ? " fm-live" : " fm-done") + '"' +
|
|
190
|
+
' data-kind="rank" data-id="' + fmEsc(rid) + '" data-worker="' + fmEsc(a.attempt_id) + '">' +
|
|
191
|
+
'<div class="fm-title">rank ' + fmEsc(r.rank) + " · pid " + fmEsc(r.pid) + "</div>" +
|
|
192
|
+
'<div class="fm-sub">' + fmEsc(r.device || "?") +
|
|
193
|
+
(r.step != null ? " · step " + fmEsc(r.step) : "") +
|
|
194
|
+
(proc && proc.cpu_percent != null ? " · cpu " + Math.round(proc.cpu_percent) + "%" : "") +
|
|
195
|
+
"</div></div>";
|
|
196
|
+
}
|
|
197
|
+
} else if (a.state === "RUNNING" && procs.length > 1) {
|
|
198
|
+
for (const p of procs.slice(1, 9)) { // [0] is the launched root itself
|
|
199
|
+
const rid = a.attempt_id + "/pid-" + p.pid;
|
|
200
|
+
const sel = fmSelected && fmSelected.kind === "rank" && fmSelected.id === rid;
|
|
201
|
+
html += '<div class="fm-node fm-rank fm-live' + (sel ? " fm-selected" : "") + '"' +
|
|
202
|
+
' data-kind="rank" data-id="' + fmEsc(rid) + '" data-worker="' + fmEsc(a.attempt_id) + '">' +
|
|
203
|
+
'<div class="fm-title">pid ' + fmEsc(p.pid) + "</div>" +
|
|
204
|
+
'<div class="fm-sub">' + fmEsc(p.cmd || "") +
|
|
205
|
+
(p.cpu_percent != null ? " · cpu " + Math.round(p.cpu_percent) + "%" : "") +
|
|
206
|
+
"</div></div>";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
html += "</div>";
|
|
211
|
+
|
|
212
|
+
el.innerHTML = html + '<svg id="fm-edges"></svg>';
|
|
213
|
+
|
|
214
|
+
el.querySelectorAll(".fm-node").forEach((node) => {
|
|
215
|
+
node.addEventListener("click", () => {
|
|
216
|
+
fmSelected = { kind: node.dataset.kind, id: node.dataset.id };
|
|
217
|
+
renderFlowmap(s); // re-render for the selection outline
|
|
218
|
+
renderDetail(s);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
fmDrawEdges(el);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Bézier connectors machine→worker and worker→its ranks, drawn into the SVG
|
|
226
|
+
// layer AFTER the DOM has laid the nodes out (positions read back from
|
|
227
|
+
// getBoundingClientRect, so the lines are correct at any width).
|
|
228
|
+
function fmDrawEdges(el) {
|
|
229
|
+
const svg = el.querySelector("#fm-edges");
|
|
230
|
+
const box = el.getBoundingClientRect();
|
|
231
|
+
svg.setAttribute("width", box.width);
|
|
232
|
+
svg.setAttribute("height", box.height);
|
|
233
|
+
const anchor = (n, side) => {
|
|
234
|
+
const r = n.getBoundingClientRect();
|
|
235
|
+
return { x: (side === "r" ? r.right : r.left) - box.left,
|
|
236
|
+
y: r.top + r.height / 2 - box.top };
|
|
237
|
+
};
|
|
238
|
+
const curve = (a, b) =>
|
|
239
|
+
'<path d="M' + a.x + " " + a.y +
|
|
240
|
+
" C" + ((a.x + b.x) / 2) + " " + a.y + "," +
|
|
241
|
+
((a.x + b.x) / 2) + " " + b.y + "," + b.x + " " + b.y +
|
|
242
|
+
'" stroke="' + T.border + '" fill="none" stroke-width="1"/>';
|
|
243
|
+
const machine = el.querySelector(".fm-machine");
|
|
244
|
+
let lines = "";
|
|
245
|
+
el.querySelectorAll(".fm-worker").forEach((w) => {
|
|
246
|
+
if (machine) lines += curve(anchor(machine, "r"), anchor(w, "l"));
|
|
247
|
+
el.querySelectorAll('.fm-rank[data-worker="' + CSS.escape(w.dataset.id) + '"]')
|
|
248
|
+
.forEach((r) => { lines += curve(anchor(w, "r"), anchor(r, "l")); });
|
|
249
|
+
});
|
|
250
|
+
svg.innerHTML = lines;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- detail panel -----------------------------------------------------------
|
|
254
|
+
// Re-rendered on every snapshot while open, so its numbers stay live.
|
|
255
|
+
function renderDetail(s) {
|
|
256
|
+
const el = document.getElementById("detail");
|
|
257
|
+
if (!fmSelected) { el.hidden = true; return; }
|
|
258
|
+
const kv = (k, v) => '<div class="fm-kv"><span>' + fmEsc(k) + "</span><b>" + fmEsc(v) + "</b></div>";
|
|
259
|
+
let body = "";
|
|
260
|
+
if (fmSelected.kind === "machine") {
|
|
261
|
+
const m = (s.monitor && s.monitor.machine) || {};
|
|
262
|
+
body = "<h3>machine · " + fmEsc(m.hostname || "localhost") + "</h3>" +
|
|
263
|
+
kv("cores", m.cpu_count != null ? m.cpu_count : "—") +
|
|
264
|
+
kv("cpu", m.cpu_percent != null ? Math.round(m.cpu_percent) + "%" : "—") +
|
|
265
|
+
kv("memory", m.mem_used != null ? fmFmtBytes(m.mem_used) + " / " + fmFmtBytes(m.mem_total) : "—") +
|
|
266
|
+
kv("load avg", m.load_avg ? m.load_avg.map((x) => x.toFixed(2)).join(" ") : "—") +
|
|
267
|
+
((m.gpus || []).map((g, i) => kv("gpu " + i, g.name + " · " + Math.round(g.util_percent) +
|
|
268
|
+
"% · " + Math.round(g.mem_used_mb) + "/" + Math.round(g.mem_total_mb) + " MB")).join("")) +
|
|
269
|
+
(m.limited ? '<div class="fm-hint">full stats: pip install "flashruntime[monitor]"</div>' : "");
|
|
270
|
+
} else {
|
|
271
|
+
const attemptId = fmSelected.kind === "worker" ? fmSelected.id : fmSelected.id.split("/")[0];
|
|
272
|
+
const a = (s.attempts || []).find((x) => x.attempt_id === attemptId);
|
|
273
|
+
if (!a) { el.hidden = true; return; }
|
|
274
|
+
if (fmSelected.kind === "worker") {
|
|
275
|
+
const dur = a.finished_at
|
|
276
|
+
? (a.finished_at - a.started_at).toFixed(1) + "s"
|
|
277
|
+
: ((Date.now() / 1000) - a.started_at).toFixed(0) + "s so far";
|
|
278
|
+
body = "<h3>" + fmEsc(a.attempt_id) + "</h3>" +
|
|
279
|
+
kv("state", a.state) + kv("pid", a.pid) + kv("job", a.job_id) + kv("runtime", dur) +
|
|
280
|
+
'<div class="fm-log">' +
|
|
281
|
+
fmEsc((a.log_tail || "").split("\n").slice(-12).join("\n") || "no log yet") + "</div>";
|
|
282
|
+
} else {
|
|
283
|
+
const key = fmSelected.id.split("/")[1];
|
|
284
|
+
const sample = fmLastSample(a);
|
|
285
|
+
const procs = (sample && sample.processes) || [];
|
|
286
|
+
const isPid = key.startsWith("pid-");
|
|
287
|
+
const r = isPid ? null : (a.ranks || []).find((x) => String(x.rank) === key);
|
|
288
|
+
const pid = isPid ? Number(key.slice(4)) : r && r.pid;
|
|
289
|
+
const proc = procs.find((p) => p.pid === pid);
|
|
290
|
+
body = "<h3>" + (r ? "rank " + fmEsc(r.rank) : "pid " + fmEsc(pid)) + "</h3>" +
|
|
291
|
+
(r ? kv("device", r.device || "—") + kv("backend", r.backend || "—") +
|
|
292
|
+
kv("world size", r.world_size) + kv("step", r.step != null ? r.step : "—") : "") +
|
|
293
|
+
kv("pid", pid != null ? pid : "—") +
|
|
294
|
+
kv("cpu", proc && proc.cpu_percent != null ? Math.round(proc.cpu_percent) + "%" : "—") +
|
|
295
|
+
kv("memory", proc ? fmFmtBytes(proc.rss_bytes) : "—") +
|
|
296
|
+
kv("status", proc ? (proc.status || "—") : a.state);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
el.innerHTML = '<button id="fm-close" class="fm-close">✕</button>' + body;
|
|
300
|
+
el.hidden = false;
|
|
301
|
+
el.querySelector("#fm-close").addEventListener("click", () => {
|
|
302
|
+
fmSelected = null;
|
|
303
|
+
el.hidden = true;
|
|
304
|
+
renderFlowmap(s);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
"""
|