omnilane 0.10.3 → 0.11.0

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.
package/ui/app.js CHANGED
@@ -2,14 +2,24 @@
2
2
 
3
3
  (function () {
4
4
  const TOKEN_STORAGE_KEY = "omnilane.live-ui.token";
5
+ const LANGUAGE_STORAGE_KEY = "omnilane.live-ui.language";
5
6
  const DETAIL_CACHE_LIMIT = 50, DETAIL_PREFETCH_LIMIT = 12, DETAIL_CONCURRENCY = 3;
6
7
  const OUTPUT_BOTTOM_THRESHOLD = 28;
7
8
  const MOBILE_QUERY = "(max-width: 760px)";
8
9
  const VALID_STATES = new Set(["starting", "running", "succeeded", "failed", "dead", "invalid"]);
10
+ const DEFAULT_LANGUAGE = "en";
11
+ const LANGUAGES = [
12
+ { code: "en", label: "English" },
13
+ { code: "ja", label: "日本語" },
14
+ { code: "ko", label: "한국어" },
15
+ { code: "zh-TW", label: "繁體中文" },
16
+ { code: "zh-CN", label: "简体中文" },
17
+ ];
9
18
 
10
19
  const elements = {
11
20
  connection: document.getElementById("connection-status"),
12
21
  connectionLabel: document.getElementById("connection-label"),
22
+ languageSelect: document.getElementById("language-select"),
13
23
  jobCount: document.getElementById("job-count"), search: document.getElementById("job-search"),
14
24
  filter: document.getElementById("status-filter"),
15
25
  filterButtons: Array.from(document.querySelectorAll(".filter-button")),
@@ -46,6 +56,8 @@
46
56
  hasSnapshot: false, unauthorized: false, authProbeInFlight: false,
47
57
  reconnectTimer: null, mobileListScroll: 0, mobileFocusId: null,
48
58
  currentDetail: null, compareReference: null,
59
+ language: DEFAULT_LANGUAGE, connection: { mode: "waiting", key: "connection.connecting" },
60
+ inspectorView: null, detailPlaceholder: null,
49
61
  };
50
62
 
51
63
  function boardUrl() { return window.location.pathname + window.location.search; }
@@ -83,13 +95,203 @@
83
95
  }
84
96
  }
85
97
 
98
+ function isSupportedLanguage(code) {
99
+ return LANGUAGES.some(function (language) {
100
+ return language.code === code;
101
+ });
102
+ }
103
+
104
+ function readStoredLanguage() {
105
+ try {
106
+ const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
107
+ return isSupportedLanguage(stored) ? stored : null;
108
+ } catch (_error) {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function storeLanguage(code) {
114
+ try {
115
+ window.localStorage.setItem(LANGUAGE_STORAGE_KEY, code);
116
+ } catch (_error) {
117
+ // A blocked local store still leaves the choice active for this page.
118
+ }
119
+ }
120
+
121
+ function matchLanguage(tag) {
122
+ if (typeof tag !== "string" || tag.length === 0) {
123
+ return null;
124
+ }
125
+ const lower = tag.toLowerCase();
126
+ if (lower === "zh" || lower.startsWith("zh-hans") || lower.startsWith("zh-cn") || lower.startsWith("zh-sg")) {
127
+ return "zh-CN";
128
+ }
129
+ if (lower.startsWith("zh")) {
130
+ return "zh-TW";
131
+ }
132
+ if (lower.startsWith("ja")) {
133
+ return "ja";
134
+ }
135
+ if (lower.startsWith("ko")) {
136
+ return "ko";
137
+ }
138
+ if (lower.startsWith("en")) {
139
+ return "en";
140
+ }
141
+ return null;
142
+ }
143
+
144
+ function detectLanguage() {
145
+ const stored = readStoredLanguage();
146
+ if (stored) {
147
+ return stored;
148
+ }
149
+ const tags = Array.isArray(window.navigator.languages) && window.navigator.languages.length > 0
150
+ ? window.navigator.languages
151
+ : [window.navigator.language];
152
+ for (let index = 0; index < tags.length; index += 1) {
153
+ const matched = matchLanguage(tags[index]);
154
+ if (matched) {
155
+ return matched;
156
+ }
157
+ }
158
+ return DEFAULT_LANGUAGE;
159
+ }
160
+
161
+ function lookupMessage(language, key) {
162
+ const table = MESSAGES[language];
163
+ return table && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
164
+ }
165
+
166
+ function selectPluralForm(forms, params) {
167
+ const count = params && typeof params.n === "number" ? params.n : 0;
168
+ let category;
169
+ try {
170
+ category = new Intl.PluralRules(state.language).select(count);
171
+ } catch (_error) {
172
+ category = count === 1 ? "one" : "other";
173
+ }
174
+ return Object.prototype.hasOwnProperty.call(forms, category) ? forms[category] : forms.other;
175
+ }
176
+
177
+ function interpolate(template, params) {
178
+ if (!params) {
179
+ return template;
180
+ }
181
+ return template.replace(/\{(\w+)\}/g, function (match, name) {
182
+ return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match;
183
+ });
184
+ }
185
+
186
+ function t(key, params) {
187
+ let entry = lookupMessage(state.language, key);
188
+ if (entry === undefined) {
189
+ entry = lookupMessage(DEFAULT_LANGUAGE, key);
190
+ }
191
+ if (entry === undefined) {
192
+ return key;
193
+ }
194
+ if (typeof entry === "object") {
195
+ entry = selectPluralForm(entry, params);
196
+ }
197
+ return interpolate(entry, params);
198
+ }
199
+
200
+ function jobStateLabel(value) {
201
+ return t("jobState." + value);
202
+ }
203
+
204
+ function applyStaticTranslations() {
205
+ document.documentElement.lang = state.language;
206
+ document.querySelectorAll("[data-i18n]").forEach(function (element) {
207
+ element.textContent = t(element.dataset.i18n);
208
+ });
209
+ document.querySelectorAll("[data-i18n-attr]").forEach(function (element) {
210
+ element.dataset.i18nAttr.split(";").forEach(function (pair) {
211
+ const separator = pair.indexOf(":");
212
+ if (separator < 0) {
213
+ return;
214
+ }
215
+ const attribute = pair.slice(0, separator).trim();
216
+ const key = pair.slice(separator + 1).trim();
217
+ if (attribute && key) {
218
+ element.setAttribute(attribute, t(key));
219
+ }
220
+ });
221
+ });
222
+ }
223
+
224
+ function populateLanguageSelect() {
225
+ const options = document.createDocumentFragment();
226
+ LANGUAGES.forEach(function (language) {
227
+ const option = document.createElement("option");
228
+ option.value = language.code;
229
+ option.lang = language.code;
230
+ option.textContent = language.label;
231
+ options.appendChild(option);
232
+ });
233
+ elements.languageSelect.replaceChildren(options);
234
+ elements.languageSelect.value = state.language;
235
+ }
236
+
237
+ function refreshLanguage() {
238
+ applyStaticTranslations();
239
+ renderConnection();
240
+ renderQueue();
241
+
242
+ if (elements.jobDetail.hidden) {
243
+ if (state.inspectorView) {
244
+ showInspectorState(state.inspectorView.code, state.inspectorView.title, state.inspectorView.message);
245
+ }
246
+ return;
247
+ }
248
+
249
+ if (state.currentDetail) {
250
+ renderDetail(state.currentDetail);
251
+ return;
252
+ }
253
+
254
+ const summary = currentSummary();
255
+ if (summary) {
256
+ renderSummary(summary);
257
+ }
258
+ if (state.detailPlaceholder === "loading" && summary) {
259
+ showDetailLoading(summary);
260
+ } else if (state.detailPlaceholder === "error") {
261
+ showDetailError();
262
+ } else {
263
+ renderCompare(null);
264
+ }
265
+ }
266
+
267
+ function setLanguage(code) {
268
+ if (!isSupportedLanguage(code) || code === state.language) {
269
+ return;
270
+ }
271
+ state.language = code;
272
+ storeLanguage(code);
273
+ elements.languageSelect.value = code;
274
+ refreshLanguage();
275
+ }
276
+
277
+ function initializeLanguage() {
278
+ state.language = detectLanguage();
279
+ populateLanguageSelect();
280
+ applyStaticTranslations();
281
+ }
282
+
86
283
  function setText(element, value) { element.textContent = value; }
87
284
 
88
285
  function textOrFallback(value, fallback) { return typeof value === "string" && value.length > 0 ? value : fallback; }
89
286
 
90
- function setConnection(mode, message) {
91
- elements.connection.dataset.mode = mode;
92
- setText(elements.connectionLabel, message);
287
+ function renderConnection() {
288
+ elements.connection.dataset.mode = state.connection.mode;
289
+ setText(elements.connectionLabel, t(state.connection.key));
290
+ }
291
+
292
+ function setConnection(mode, key) {
293
+ state.connection = { mode: mode, key: key };
294
+ renderConnection();
93
295
  }
94
296
 
95
297
  function setControlsDisabled(disabled) {
@@ -99,10 +301,11 @@
99
301
  });
100
302
  }
101
303
 
102
- function showInspectorState(code, title, message) {
103
- setText(elements.stateCode, code);
104
- setText(elements.stateTitle, title);
105
- setText(elements.stateMessage, message);
304
+ function showInspectorState(codeKey, titleKey, messageKey) {
305
+ state.inspectorView = { code: codeKey, title: titleKey, message: messageKey };
306
+ setText(elements.stateCode, t(codeKey));
307
+ setText(elements.stateTitle, t(titleKey));
308
+ setText(elements.stateMessage, t(messageKey));
106
309
  elements.inspectorState.hidden = false;
107
310
  elements.jobDetail.hidden = true;
108
311
  }
@@ -137,41 +340,41 @@
137
340
  clearStoredToken();
138
341
  closeEventStream();
139
342
  cancelDetailRequests(true);
140
- setConnection("unauthorized", "Not authorized");
141
- setText(elements.jobCount, "0 jobs");
343
+ setConnection("unauthorized", "connection.unauthorized");
344
+ setText(elements.jobCount, t("count.jobs", { n: 0 }));
142
345
  setControlsDisabled(true);
143
346
  renderQueue();
144
347
  setMobileView("list", false);
145
348
  showInspectorState(
146
- "Local · read only",
147
- "Local access required",
148
- "Run `omnilane ui url` for a fresh local link."
349
+ "state.local.code",
350
+ "state.unauthorized.title",
351
+ "state.unauthorized.message"
149
352
  );
150
353
  }
151
354
 
152
355
  function showNoJobs() {
153
356
  showInspectorState(
154
- "Queue · empty",
155
- "No tasks yet",
156
- "Run an Omnilane task. This board will update automatically."
357
+ "state.empty.code",
358
+ "state.empty.title",
359
+ "state.empty.message"
157
360
  );
158
361
  }
159
362
 
160
363
  function showNoMatches() {
161
364
  showInspectorState(
162
- "Filter · no match",
163
- "No matching tasks",
164
- "Clear the search or choose another state."
365
+ "state.noMatch.code",
366
+ "state.noMatch.title",
367
+ "state.noMatch.message"
165
368
  );
166
369
  }
167
370
 
168
371
  function showReconnecting() {
169
- setConnection("reconnecting", "Reconnecting");
372
+ setConnection("reconnecting", "connection.reconnecting");
170
373
  if (!state.hasSnapshot) {
171
374
  showInspectorState(
172
- "Link · retrying",
173
- "Local board unavailable",
174
- "Reconnecting to the local job board."
375
+ "state.retry.code",
376
+ "state.retry.title",
377
+ "state.retry.message"
175
378
  );
176
379
  }
177
380
  }
@@ -226,7 +429,7 @@
226
429
  function taskSummary(job) {
227
430
  const detail = cachedDetail(job.id);
228
431
  if (!detail || typeof detail.task !== "string" || detail.task.trim().length === 0) {
229
- return "Loading task";
432
+ return t("loading.task");
230
433
  }
231
434
  const compact = detail.task.replace(/\s+/g, " ").trim();
232
435
  return compact.length > 150 ? compact.slice(0, 147) + "…" : compact;
@@ -253,6 +456,7 @@
253
456
  const searchText = [
254
457
  job.id,
255
458
  job.state,
459
+ jobStateLabel(job.state),
256
460
  taskSummary(job),
257
461
  textOrFallback(meta.lane, ""),
258
462
  textOrFallback(meta.vendor, ""),
@@ -270,9 +474,9 @@
270
474
  function routeLabel(job) {
271
475
  const meta = job.meta;
272
476
  return [
273
- textOrFallback(meta.lane, "Unknown lane"),
274
- textOrFallback(meta.vendor, "Unknown vendor"),
275
- textOrFallback(meta.model, "Unknown model"),
477
+ textOrFallback(meta.lane, t("value.unknownLane")),
478
+ textOrFallback(meta.vendor, t("value.unknownVendor")),
479
+ textOrFallback(meta.model, t("value.unknownModel")),
276
480
  ].join(" · ");
277
481
  }
278
482
 
@@ -316,7 +520,7 @@
316
520
  setText(button.querySelector(".card-route"), routeLabel(job));
317
521
  const stateElement = button.querySelector(".card-state");
318
522
  stateElement.className = "card-state state-" + job.state;
319
- setText(stateElement, job.state);
523
+ setText(stateElement, jobStateLabel(job.state));
320
524
  }
321
525
 
322
526
  function renderQueue() {
@@ -357,18 +561,18 @@
357
561
  }
358
562
 
359
563
  if (state.jobs.length === 0) {
360
- setText(elements.listMessage, state.unauthorized ? "A fresh local link is required." : "No tasks yet.");
564
+ setText(elements.listMessage, state.unauthorized ? t("list.unauthorized") : t("list.empty"));
361
565
  elements.listMessage.hidden = false;
362
566
  } else if (jobs.length === 0) {
363
- setText(elements.listMessage, "No tasks match this filter.");
567
+ setText(elements.listMessage, t("list.noMatch"));
364
568
  elements.listMessage.hidden = false;
365
569
  } else {
366
570
  elements.listMessage.hidden = true;
367
571
  }
368
572
 
369
573
  const countLabel = jobs.length === state.jobs.length
370
- ? String(state.jobs.length) + (state.jobs.length === 1 ? " job" : " jobs")
371
- : String(jobs.length) + " / " + String(state.jobs.length) + " jobs";
574
+ ? t("count.jobs", { n: state.jobs.length })
575
+ : t("count.filtered", { shown: jobs.length, total: state.jobs.length });
372
576
  setText(elements.jobCount, countLabel);
373
577
  }
374
578
 
@@ -521,7 +725,7 @@
521
725
  state.hasSnapshot = true;
522
726
  state.unauthorized = false;
523
727
  setControlsDisabled(false);
524
- setConnection("live", "Live local signal");
728
+ setConnection("live", "connection.live");
525
729
 
526
730
  const jobs = reconcileSelection();
527
731
  renderQueue();
@@ -589,7 +793,7 @@
589
793
  function comparisonOutput(snapshot) {
590
794
  const invalidFiles = invalidFileSet(snapshot);
591
795
  if (invalidFiles.has("out.txt")) {
592
- return "The public result could not be read safely.";
796
+ return t("result.unsafe");
593
797
  }
594
798
  if (snapshot.output.length > 0) {
595
799
  return snapshot.output;
@@ -601,10 +805,10 @@
601
805
  const summary = snapshot.summary;
602
806
  const meta = summary.meta;
603
807
  setText(elements[prefix + "Id"], summary.id);
604
- setText(elements[prefix + "Lane"], metadataValue(meta, "lane", "Unknown"));
605
- setText(elements[prefix + "Vendor"], metadataValue(meta, "vendor", "Unknown"));
606
- setText(elements[prefix + "Model"], metadataValue(meta, "model", "Unknown"));
607
- setText(elements[prefix + "State"], summary.state);
808
+ setText(elements[prefix + "Lane"], metadataValue(meta, "lane", t("value.unknown")));
809
+ setText(elements[prefix + "Vendor"], metadataValue(meta, "vendor", t("value.unknown")));
810
+ setText(elements[prefix + "Model"], metadataValue(meta, "model", t("value.unknown")));
811
+ setText(elements[prefix + "State"], jobStateLabel(summary.state));
608
812
  setText(elements[prefix + "Output"], comparisonOutput(snapshot));
609
813
  }
610
814
 
@@ -616,11 +820,11 @@
616
820
  elements.compareToggle.setAttribute("aria-pressed", isReference ? "true" : "false");
617
821
  setText(
618
822
  elements.compareToggle,
619
- isReference ? "Unpin reference" : reference ? "Replace reference" : "Pin for compare"
823
+ isReference ? t("compare.unpin") : reference ? t("compare.replace") : t("compare.pin")
620
824
  );
621
825
  setText(
622
826
  elements.compareReferenceLabel,
623
- reference ? "Reference " + reference.summary.id + " pinned" : "No reference pinned"
827
+ reference ? t("compare.pinned", { id: reference.summary.id }) : t("compare.none")
624
828
  );
625
829
 
626
830
  if (!reference || !current || isReference) {
@@ -659,20 +863,26 @@
659
863
  elements.inspectorState.hidden = true;
660
864
  elements.jobDetail.hidden = false;
661
865
  setText(elements.selectedJobId, summary.id);
662
- setText(elements.selectedJobState, summary.state);
663
- setText(elements.selectedJobTime, "Started " + metadataValue(meta, "started", "time unavailable"));
866
+ setText(elements.selectedJobState, jobStateLabel(summary.state));
867
+ setText(
868
+ elements.selectedJobTime,
869
+ t("detail.timeStarted", { value: metadataValue(meta, "started", t("detail.timeUnavailable")) })
870
+ );
664
871
  setStateClass(elements.selectedJobState, summary.state);
665
872
  elements.routeTrack.dataset.state = summary.state;
666
- setText(elements.routeLane, metadataValue(meta, "lane", "Unknown"));
667
- setText(elements.routeVendor, metadataValue(meta, "vendor", "Unknown"));
668
- setText(elements.routeModel, metadataValue(meta, "model", "Unknown"));
669
- setText(elements.routeState, summary.state);
670
- setText(elements.factEffort, metadataValue(meta, "effort", "Not recorded"));
671
- setText(elements.factMode, metadataValue(meta, "mode", "Not recorded"));
672
- setText(elements.factTimeout, Number.isInteger(meta.timeout) ? String(meta.timeout) + " s" : "Not recorded");
673
- setText(elements.factCandidate, metadataValue(meta, "candidate", "Not recorded"));
674
- setText(elements.factStarted, metadataValue(meta, "started", "Not recorded"));
675
- setText(elements.factWorkdir, metadataValue(meta, "workdir", "Not recorded"));
873
+ setText(elements.routeLane, metadataValue(meta, "lane", t("value.unknown")));
874
+ setText(elements.routeVendor, metadataValue(meta, "vendor", t("value.unknown")));
875
+ setText(elements.routeModel, metadataValue(meta, "model", t("value.unknown")));
876
+ setText(elements.routeState, jobStateLabel(summary.state));
877
+ setText(elements.factEffort, metadataValue(meta, "effort", t("value.notRecorded")));
878
+ setText(elements.factMode, metadataValue(meta, "mode", t("value.notRecorded")));
879
+ setText(
880
+ elements.factTimeout,
881
+ Number.isInteger(meta.timeout) ? t("value.seconds", { n: meta.timeout }) : t("value.notRecorded")
882
+ );
883
+ setText(elements.factCandidate, metadataValue(meta, "candidate", t("value.notRecorded")));
884
+ setText(elements.factStarted, metadataValue(meta, "started", t("value.notRecorded")));
885
+ setText(elements.factWorkdir, metadataValue(meta, "workdir", t("value.notRecorded")));
676
886
  }
677
887
 
678
888
  function isMobile() {
@@ -759,6 +969,7 @@
759
969
 
760
970
  function showDetailLoading(job) {
761
971
  state.currentDetail = null;
972
+ state.detailPlaceholder = "loading";
762
973
  renderCompare(null);
763
974
  clearMarkers(elements.requestMarkers);
764
975
  clearMarkers(elements.resultMarkers);
@@ -766,8 +977,8 @@
766
977
  elements.resultContent.hidden = true;
767
978
  elements.requestEmpty.hidden = false;
768
979
  elements.resultEmpty.hidden = false;
769
- setText(elements.requestEmpty, "Loading task");
770
- setText(elements.resultEmpty, job.state === "running" ? "Worker is running. Waiting for output…" : "Loading output");
980
+ setText(elements.requestEmpty, t("loading.task"));
981
+ setText(elements.resultEmpty, job.state === "running" ? t("loading.running") : t("loading.output"));
771
982
  }
772
983
 
773
984
  function fetchDetail(jobId, showLoading) {
@@ -817,22 +1028,23 @@
817
1028
 
818
1029
  function emptyResultMessage(summary) {
819
1030
  if (summary.state === "starting") {
820
- return "Dispatch is starting. No public result has been recorded yet.";
1031
+ return t("result.starting");
821
1032
  }
822
1033
  if (summary.state === "running") {
823
- return "Worker is running. The public result will appear here.";
1034
+ return t("result.running");
824
1035
  }
825
1036
  if (summary.state === "failed") {
826
- const code = summary.exitCode === null ? "an unknown code" : "exit code " + String(summary.exitCode);
827
- return "Dispatch failed with " + code + ". No public result was recorded.";
1037
+ return summary.exitCode === null
1038
+ ? t("result.failedUnknown")
1039
+ : t("result.failedCode", { code: summary.exitCode });
828
1040
  }
829
1041
  if (summary.state === "dead") {
830
- return "The worker is gone and no exit code was recorded.";
1042
+ return t("result.dead");
831
1043
  }
832
1044
  if (summary.state === "invalid") {
833
- return "This dispatch contains invalid metadata or control files. No safe result is available.";
1045
+ return t("result.invalid");
834
1046
  }
835
- return "Dispatch completed without a public result.";
1047
+ return t("result.none");
836
1048
  }
837
1049
 
838
1050
  function renderPlainText(contentElement, emptyElement, value, emptyMessage, followBottom) {
@@ -872,6 +1084,7 @@
872
1084
  }
873
1085
 
874
1086
  state.currentDetail = detail;
1087
+ state.detailPlaceholder = null;
875
1088
  renderSummary(summary);
876
1089
  renderCompare(detail);
877
1090
  clearMarkers(elements.requestMarkers);
@@ -879,53 +1092,54 @@
879
1092
  const invalidFiles = invalidFileSet(detail);
880
1093
 
881
1094
  if (detail.taskTruncated === true) {
882
- addMarker(elements.requestMarkers, "Large content shortened to 512 KiB", false);
1095
+ addMarker(elements.requestMarkers, t("marker.truncated"), false);
883
1096
  }
884
1097
  if (detail.outputTruncated === true) {
885
- addMarker(elements.resultMarkers, "Large content shortened to 512 KiB", false);
1098
+ addMarker(elements.resultMarkers, t("marker.truncated"), false);
886
1099
  }
887
1100
  if (invalidFiles.has("task.txt")) {
888
- addMarker(elements.requestMarkers, "Task file could not be read safely", true);
1101
+ addMarker(elements.requestMarkers, t("marker.taskUnsafe"), true);
889
1102
  }
890
1103
  if (invalidFiles.has("out.txt")) {
891
- addMarker(elements.resultMarkers, "Result file could not be read safely", true);
1104
+ addMarker(elements.resultMarkers, t("marker.resultUnsafe"), true);
892
1105
  }
893
1106
  if (summary.state === "failed") {
894
1107
  addMarker(
895
1108
  elements.resultMarkers,
896
1109
  summary.exitCode === null
897
- ? "Dispatch failed without a readable exit code"
898
- : "Dispatch failed with exit code " + String(summary.exitCode),
1110
+ ? t("marker.failedNoCode")
1111
+ : t("marker.failedCode", { code: summary.exitCode }),
899
1112
  true
900
1113
  );
901
1114
  } else if (summary.state === "dead") {
902
- addMarker(elements.resultMarkers, "Worker gone · exit code not recorded", true);
1115
+ addMarker(elements.resultMarkers, t("marker.dead"), true);
903
1116
  } else if (summary.state === "invalid") {
904
- addMarker(elements.resultMarkers, "Invalid dispatch · safe fields only", true);
1117
+ addMarker(elements.resultMarkers, t("marker.invalid"), true);
905
1118
  }
906
1119
 
907
1120
  renderPlainText(
908
1121
  elements.requestContent,
909
1122
  elements.requestEmpty,
910
1123
  detail.task,
911
- invalidFiles.has("task.txt") ? "The task could not be read safely." : "No task was recorded.",
1124
+ invalidFiles.has("task.txt") ? t("task.unsafe") : t("task.none"),
912
1125
  false
913
1126
  );
914
1127
  renderPlainText(
915
1128
  elements.resultContent,
916
1129
  elements.resultEmpty,
917
1130
  detail.output,
918
- invalidFiles.has("out.txt") ? "The public result could not be read safely." : emptyResultMessage(summary),
1131
+ invalidFiles.has("out.txt") ? t("result.unsafe") : emptyResultMessage(summary),
919
1132
  true
920
1133
  );
921
1134
  }
922
1135
 
923
1136
  function showDetailError() {
1137
+ state.detailPlaceholder = "error";
924
1138
  clearMarkers(elements.requestMarkers);
925
1139
  clearMarkers(elements.resultMarkers);
926
- addMarker(elements.resultMarkers, "Detail temporarily unavailable", true);
927
- setText(elements.requestEmpty, "The last task snapshot is preserved while detail reloads.");
928
- setText(elements.resultEmpty, "Reconnecting to the local job board.");
1140
+ addMarker(elements.resultMarkers, t("marker.detailUnavailable"), true);
1141
+ setText(elements.requestEmpty, t("error.taskPreserved"));
1142
+ setText(elements.resultEmpty, t("error.reconnecting"));
929
1143
  elements.requestEmpty.hidden = false;
930
1144
  elements.resultEmpty.hidden = false;
931
1145
  }
@@ -1029,6 +1243,10 @@
1029
1243
  }
1030
1244
 
1031
1245
  function bindControls() {
1246
+ elements.languageSelect.addEventListener("change", function () {
1247
+ setLanguage(elements.languageSelect.value);
1248
+ });
1249
+
1032
1250
  elements.search.addEventListener("input", function () {
1033
1251
  state.query = elements.search.value.trim().toLocaleLowerCase();
1034
1252
  updateFilterSelection();
@@ -1088,7 +1306,568 @@
1088
1306
  setMobileView("list", false);
1089
1307
  }
1090
1308
 
1309
+ // The locale tables sit after the board logic so the readable code stays at the
1310
+ // top of the file; nothing reads MESSAGES until start() runs at the very end.
1311
+ const MESSAGES = {
1312
+ en: {
1313
+ "app.title": "Omnilane Live Board",
1314
+ "header.liveJobs": "Live jobs",
1315
+ "header.readOnly": "Read only",
1316
+ "header.language": "Language",
1317
+ "connection.connecting": "Connecting",
1318
+ "connection.live": "Live local signal",
1319
+ "connection.reconnecting": "Reconnecting",
1320
+ "connection.unauthorized": "Not authorized",
1321
+ "index.sectionLabel": "Recent work",
1322
+ "index.title": "Task record",
1323
+ "search.label": "Find a task",
1324
+ "search.placeholder": "Task, ID, lane, model",
1325
+ "filter.legend": "Show",
1326
+ "filter.all": "All",
1327
+ "filter.active": "Active",
1328
+ "filter.done": "Done",
1329
+ "filter.issues": "Issues",
1330
+ "list.ariaLabel": "Recent tasks",
1331
+ "list.waiting": "Waiting for tasks.",
1332
+ "list.empty": "No tasks yet.",
1333
+ "list.unauthorized": "A fresh local link is required.",
1334
+ "list.noMatch": "No tasks match this filter.",
1335
+ "count.jobs": { one: "{n} job", other: "{n} jobs" },
1336
+ "count.filtered": "{shown} / {total} jobs",
1337
+ "inspector.ariaLabel": "Selected task detail",
1338
+ "state.local.code": "Local · read only",
1339
+ "state.connecting.title": "Connecting to the job board",
1340
+ "state.connecting.message": "The newest task will appear here.",
1341
+ "state.unauthorized.title": "Local access required",
1342
+ "state.unauthorized.message": "Run `omnilane ui url` for a fresh local link.",
1343
+ "state.empty.code": "Queue · empty",
1344
+ "state.empty.title": "No tasks yet",
1345
+ "state.empty.message": "Run an Omnilane task. This board will update automatically.",
1346
+ "state.noMatch.code": "Filter · no match",
1347
+ "state.noMatch.title": "No matching tasks",
1348
+ "state.noMatch.message": "Clear the search or choose another state.",
1349
+ "state.retry.code": "Link · retrying",
1350
+ "state.retry.title": "Local board unavailable",
1351
+ "state.retry.message": "Reconnecting to the local job board.",
1352
+ "mobile.back": "Back to tasks",
1353
+ "detail.sectionLabel": "Selected task",
1354
+ "detail.timeStarted": "Started {value}",
1355
+ "detail.timeUnavailable": "time unavailable",
1356
+ "detail.timeUnknown": "Started time unavailable",
1357
+ "compare.pin": "Pin for compare",
1358
+ "compare.unpin": "Unpin reference",
1359
+ "compare.replace": "Replace reference",
1360
+ "compare.none": "No reference pinned",
1361
+ "compare.pinned": "Reference {id} pinned",
1362
+ "compare.sectionLabel": "Reference snapshot",
1363
+ "compare.heading": "Route and result comparison",
1364
+ "compare.clear": "Clear reference",
1365
+ "compare.roleReference": "Pinned reference",
1366
+ "compare.roleCurrent": "Current selection",
1367
+ "compare.outputLabel": "Public result",
1368
+ "field.lane": "Lane",
1369
+ "field.vendor": "Vendor",
1370
+ "field.model": "Model",
1371
+ "field.state": "State",
1372
+ "field.effort": "Effort",
1373
+ "field.mode": "Mode",
1374
+ "field.watchdog": "Watchdog",
1375
+ "field.candidate": "Candidate",
1376
+ "field.started": "Started",
1377
+ "field.workdir": "Workdir",
1378
+ "task.sectionLabel": "Task",
1379
+ "task.heading": "What the model was asked",
1380
+ "output.sectionLabel": "Output",
1381
+ "output.heading": "Public result",
1382
+ "routing.sectionLabel": "Routing",
1383
+ "routing.heading": "Model path",
1384
+ "meta.summary": "Technical metadata",
1385
+ "meta.ariaLabel": "Task metadata",
1386
+ "value.unknown": "Unknown",
1387
+ "value.notRecorded": "Not recorded",
1388
+ "value.unknownLane": "Unknown lane",
1389
+ "value.unknownVendor": "Unknown vendor",
1390
+ "value.unknownModel": "Unknown model",
1391
+ "value.seconds": "{n} s",
1392
+ "loading.task": "Loading task…",
1393
+ "loading.output": "Loading output…",
1394
+ "loading.running": "Worker is running. Waiting for output…",
1395
+ "result.starting": "Dispatch is starting. No public result has been recorded yet.",
1396
+ "result.running": "Worker is running. The public result will appear here.",
1397
+ "result.failedCode": "Dispatch failed with exit code {code}. No public result was recorded.",
1398
+ "result.failedUnknown": "Dispatch failed with an unknown code. No public result was recorded.",
1399
+ "result.dead": "The worker is gone and no exit code was recorded.",
1400
+ "result.invalid": "This dispatch contains invalid metadata or control files. No safe result is available.",
1401
+ "result.none": "Dispatch completed without a public result.",
1402
+ "result.unsafe": "The public result could not be read safely.",
1403
+ "task.unsafe": "The task could not be read safely.",
1404
+ "task.none": "No task was recorded.",
1405
+ "marker.truncated": "Large content shortened to 512 KiB",
1406
+ "marker.taskUnsafe": "Task file could not be read safely",
1407
+ "marker.resultUnsafe": "Result file could not be read safely",
1408
+ "marker.failedNoCode": "Dispatch failed without a readable exit code",
1409
+ "marker.failedCode": "Dispatch failed with exit code {code}",
1410
+ "marker.dead": "Worker gone · exit code not recorded",
1411
+ "marker.invalid": "Invalid dispatch · safe fields only",
1412
+ "marker.detailUnavailable": "Detail temporarily unavailable",
1413
+ "error.taskPreserved": "The last task snapshot is preserved while detail reloads.",
1414
+ "error.reconnecting": "Reconnecting to the local job board.",
1415
+ "jobState.starting": "starting",
1416
+ "jobState.running": "running",
1417
+ "jobState.succeeded": "succeeded",
1418
+ "jobState.failed": "failed",
1419
+ "jobState.dead": "dead",
1420
+ "jobState.invalid": "invalid",
1421
+ "noscript": "JavaScript is required to read this local board.",
1422
+ },
1423
+ ja: {
1424
+ "app.title": "Omnilane ライブボード",
1425
+ "header.liveJobs": "実行中のジョブ",
1426
+ "header.readOnly": "読み取り専用",
1427
+ "header.language": "言語",
1428
+ "connection.connecting": "接続中",
1429
+ "connection.live": "ローカル信号を受信中",
1430
+ "connection.reconnecting": "再接続中",
1431
+ "connection.unauthorized": "未認証",
1432
+ "index.sectionLabel": "最近の作業",
1433
+ "index.title": "タスク記録",
1434
+ "search.label": "タスクを検索",
1435
+ "search.placeholder": "タスク、ID、レーン、モデル",
1436
+ "filter.legend": "表示",
1437
+ "filter.all": "すべて",
1438
+ "filter.active": "実行中",
1439
+ "filter.done": "完了",
1440
+ "filter.issues": "問題あり",
1441
+ "list.ariaLabel": "最近のタスク",
1442
+ "list.waiting": "タスクを待機しています。",
1443
+ "list.empty": "タスクはまだありません。",
1444
+ "list.unauthorized": "新しいローカルリンクが必要です。",
1445
+ "list.noMatch": "このフィルターに一致するタスクはありません。",
1446
+ "count.jobs": { other: "{n} 件のジョブ" },
1447
+ "count.filtered": "{shown} / {total} 件のジョブ",
1448
+ "inspector.ariaLabel": "選択したタスクの詳細",
1449
+ "state.local.code": "ローカル · 読み取り専用",
1450
+ "state.connecting.title": "ジョブボードに接続しています",
1451
+ "state.connecting.message": "最新のタスクがここに表示されます。",
1452
+ "state.unauthorized.title": "ローカルアクセスが必要です",
1453
+ "state.unauthorized.message": "`omnilane ui url` を実行して新しいローカルリンクを取得してください。",
1454
+ "state.empty.code": "キュー · 空",
1455
+ "state.empty.title": "タスクはまだありません",
1456
+ "state.empty.message": "Omnilane タスクを実行してください。このボードは自動的に更新されます。",
1457
+ "state.noMatch.code": "フィルター · 一致なし",
1458
+ "state.noMatch.title": "一致するタスクがありません",
1459
+ "state.noMatch.message": "検索条件を消すか、別の状態を選んでください。",
1460
+ "state.retry.code": "リンク · 再試行中",
1461
+ "state.retry.title": "ローカルボードに接続できません",
1462
+ "state.retry.message": "ローカルジョブボードに再接続しています。",
1463
+ "mobile.back": "タスク一覧に戻る",
1464
+ "detail.sectionLabel": "選択したタスク",
1465
+ "detail.timeStarted": "開始 {value}",
1466
+ "detail.timeUnavailable": "時刻不明",
1467
+ "detail.timeUnknown": "開始 時刻不明",
1468
+ "compare.pin": "比較用にピン留め",
1469
+ "compare.unpin": "ピン留めを解除",
1470
+ "compare.replace": "基準を差し替える",
1471
+ "compare.none": "基準は未設定",
1472
+ "compare.pinned": "基準 {id} をピン留め中",
1473
+ "compare.sectionLabel": "基準スナップショット",
1474
+ "compare.heading": "ルートと結果の比較",
1475
+ "compare.clear": "基準をクリア",
1476
+ "compare.roleReference": "ピン留めした基準",
1477
+ "compare.roleCurrent": "現在の選択",
1478
+ "compare.outputLabel": "公開結果",
1479
+ "field.lane": "レーン",
1480
+ "field.vendor": "ベンダー",
1481
+ "field.model": "モデル",
1482
+ "field.state": "状態",
1483
+ "field.effort": "エフォート",
1484
+ "field.mode": "モード",
1485
+ "field.watchdog": "ウォッチドッグ",
1486
+ "field.candidate": "候補",
1487
+ "field.started": "開始時刻",
1488
+ "field.workdir": "作業ディレクトリ",
1489
+ "task.sectionLabel": "タスク",
1490
+ "task.heading": "モデルへの依頼内容",
1491
+ "output.sectionLabel": "出力",
1492
+ "output.heading": "公開結果",
1493
+ "routing.sectionLabel": "ルーティング",
1494
+ "routing.heading": "モデル経路",
1495
+ "meta.summary": "技術メタデータ",
1496
+ "meta.ariaLabel": "タスクのメタデータ",
1497
+ "value.unknown": "不明",
1498
+ "value.notRecorded": "記録なし",
1499
+ "value.unknownLane": "レーン不明",
1500
+ "value.unknownVendor": "ベンダー不明",
1501
+ "value.unknownModel": "モデル不明",
1502
+ "value.seconds": "{n} 秒",
1503
+ "loading.task": "タスクを読み込み中…",
1504
+ "loading.output": "出力を読み込み中…",
1505
+ "loading.running": "ワーカーが実行中です。出力を待っています…",
1506
+ "result.starting": "ディスパッチを開始しています。公開結果はまだ記録されていません。",
1507
+ "result.running": "ワーカーが実行中です。公開結果はここに表示されます。",
1508
+ "result.failedCode": "ディスパッチは終了コード {code} で失敗しました。公開結果は記録されていません。",
1509
+ "result.failedUnknown": "ディスパッチは不明なコードで失敗しました。公開結果は記録されていません。",
1510
+ "result.dead": "ワーカーが消失し、終了コードは記録されていません。",
1511
+ "result.invalid": "このディスパッチには無効なメタデータまたは制御ファイルが含まれています。安全な結果はありません。",
1512
+ "result.none": "ディスパッチは公開結果なしで完了しました。",
1513
+ "result.unsafe": "公開結果を安全に読み取れませんでした。",
1514
+ "task.unsafe": "タスクを安全に読み取れませんでした。",
1515
+ "task.none": "タスクは記録されていません。",
1516
+ "marker.truncated": "大きな内容を 512 KiB に短縮しました",
1517
+ "marker.taskUnsafe": "タスクファイルを安全に読み取れませんでした",
1518
+ "marker.resultUnsafe": "結果ファイルを安全に読み取れませんでした",
1519
+ "marker.failedNoCode": "終了コードを読み取れないまま失敗しました",
1520
+ "marker.failedCode": "終了コード {code} で失敗しました",
1521
+ "marker.dead": "ワーカー消失 · 終了コード未記録",
1522
+ "marker.invalid": "無効なディスパッチ · 安全な項目のみ",
1523
+ "marker.detailUnavailable": "詳細を一時的に取得できません",
1524
+ "error.taskPreserved": "詳細の再読み込み中は直前のタスクを表示しています。",
1525
+ "error.reconnecting": "ローカルジョブボードに再接続しています。",
1526
+ "jobState.starting": "開始中",
1527
+ "jobState.running": "実行中",
1528
+ "jobState.succeeded": "成功",
1529
+ "jobState.failed": "失敗",
1530
+ "jobState.dead": "消失",
1531
+ "jobState.invalid": "無効",
1532
+ "noscript": "このローカルボードを表示するには JavaScript が必要です。",
1533
+ },
1534
+ ko: {
1535
+ "app.title": "Omnilane 라이브 보드",
1536
+ "header.liveJobs": "실행 중인 작업",
1537
+ "header.readOnly": "읽기 전용",
1538
+ "header.language": "언어",
1539
+ "connection.connecting": "연결 중",
1540
+ "connection.live": "로컬 신호 수신 중",
1541
+ "connection.reconnecting": "다시 연결 중",
1542
+ "connection.unauthorized": "인증되지 않음",
1543
+ "index.sectionLabel": "최근 작업",
1544
+ "index.title": "작업 기록",
1545
+ "search.label": "작업 찾기",
1546
+ "search.placeholder": "작업, ID, 레인, 모델",
1547
+ "filter.legend": "표시",
1548
+ "filter.all": "전체",
1549
+ "filter.active": "진행 중",
1550
+ "filter.done": "완료",
1551
+ "filter.issues": "문제",
1552
+ "list.ariaLabel": "최근 작업",
1553
+ "list.waiting": "작업을 기다리는 중입니다.",
1554
+ "list.empty": "아직 작업이 없습니다.",
1555
+ "list.unauthorized": "새 로컬 링크가 필요합니다.",
1556
+ "list.noMatch": "이 필터와 일치하는 작업이 없습니다.",
1557
+ "count.jobs": { other: "작업 {n}개" },
1558
+ "count.filtered": "작업 {shown} / {total}개",
1559
+ "inspector.ariaLabel": "선택한 작업 상세",
1560
+ "state.local.code": "로컬 · 읽기 전용",
1561
+ "state.connecting.title": "작업 보드에 연결하는 중",
1562
+ "state.connecting.message": "가장 최근 작업이 여기에 표시됩니다.",
1563
+ "state.unauthorized.title": "로컬 접근 권한이 필요합니다",
1564
+ "state.unauthorized.message": "`omnilane ui url` 을 실행해 새 로컬 링크를 받으세요.",
1565
+ "state.empty.code": "대기열 · 비어 있음",
1566
+ "state.empty.title": "아직 작업이 없습니다",
1567
+ "state.empty.message": "Omnilane 작업을 실행하세요. 이 보드는 자동으로 갱신됩니다.",
1568
+ "state.noMatch.code": "필터 · 일치 없음",
1569
+ "state.noMatch.title": "일치하는 작업이 없습니다",
1570
+ "state.noMatch.message": "검색어를 지우거나 다른 상태를 선택하세요.",
1571
+ "state.retry.code": "링크 · 재시도 중",
1572
+ "state.retry.title": "로컬 보드를 사용할 수 없습니다",
1573
+ "state.retry.message": "로컬 작업 보드에 다시 연결하는 중입니다.",
1574
+ "mobile.back": "작업 목록으로",
1575
+ "detail.sectionLabel": "선택한 작업",
1576
+ "detail.timeStarted": "시작 {value}",
1577
+ "detail.timeUnavailable": "시각 불명",
1578
+ "detail.timeUnknown": "시작 시각 불명",
1579
+ "compare.pin": "비교용으로 고정",
1580
+ "compare.unpin": "고정 해제",
1581
+ "compare.replace": "기준 교체",
1582
+ "compare.none": "고정된 기준 없음",
1583
+ "compare.pinned": "기준 {id} 고정됨",
1584
+ "compare.sectionLabel": "기준 스냅샷",
1585
+ "compare.heading": "경로와 결과 비교",
1586
+ "compare.clear": "기준 지우기",
1587
+ "compare.roleReference": "고정된 기준",
1588
+ "compare.roleCurrent": "현재 선택",
1589
+ "compare.outputLabel": "공개 결과",
1590
+ "field.lane": "레인",
1591
+ "field.vendor": "벤더",
1592
+ "field.model": "모델",
1593
+ "field.state": "상태",
1594
+ "field.effort": "노력 수준",
1595
+ "field.mode": "모드",
1596
+ "field.watchdog": "워치독",
1597
+ "field.candidate": "후보",
1598
+ "field.started": "시작 시각",
1599
+ "field.workdir": "작업 디렉터리",
1600
+ "task.sectionLabel": "작업",
1601
+ "task.heading": "모델에 요청한 내용",
1602
+ "output.sectionLabel": "출력",
1603
+ "output.heading": "공개 결과",
1604
+ "routing.sectionLabel": "라우팅",
1605
+ "routing.heading": "모델 경로",
1606
+ "meta.summary": "기술 메타데이터",
1607
+ "meta.ariaLabel": "작업 메타데이터",
1608
+ "value.unknown": "알 수 없음",
1609
+ "value.notRecorded": "기록 없음",
1610
+ "value.unknownLane": "레인 불명",
1611
+ "value.unknownVendor": "벤더 불명",
1612
+ "value.unknownModel": "모델 불명",
1613
+ "value.seconds": "{n}초",
1614
+ "loading.task": "작업을 불러오는 중…",
1615
+ "loading.output": "출력을 불러오는 중…",
1616
+ "loading.running": "워커가 실행 중입니다. 출력을 기다리는 중…",
1617
+ "result.starting": "디스패치를 시작하는 중입니다. 아직 기록된 공개 결과가 없습니다.",
1618
+ "result.running": "워커가 실행 중입니다. 공개 결과가 여기에 표시됩니다.",
1619
+ "result.failedCode": "디스패치가 종료 코드 {code}로 실패했습니다. 공개 결과가 기록되지 않았습니다.",
1620
+ "result.failedUnknown": "디스패치가 알 수 없는 코드로 실패했습니다. 공개 결과가 기록되지 않았습니다.",
1621
+ "result.dead": "워커가 사라졌고 종료 코드가 기록되지 않았습니다.",
1622
+ "result.invalid": "이 디스패치에는 잘못된 메타데이터나 제어 파일이 있습니다. 안전한 결과가 없습니다.",
1623
+ "result.none": "디스패치가 공개 결과 없이 완료되었습니다.",
1624
+ "result.unsafe": "공개 결과를 안전하게 읽을 수 없습니다.",
1625
+ "task.unsafe": "작업 내용을 안전하게 읽을 수 없습니다.",
1626
+ "task.none": "기록된 작업이 없습니다.",
1627
+ "marker.truncated": "큰 내용을 512 KiB로 줄였습니다",
1628
+ "marker.taskUnsafe": "작업 파일을 안전하게 읽을 수 없습니다",
1629
+ "marker.resultUnsafe": "결과 파일을 안전하게 읽을 수 없습니다",
1630
+ "marker.failedNoCode": "종료 코드를 읽지 못한 채 실패했습니다",
1631
+ "marker.failedCode": "종료 코드 {code}로 실패했습니다",
1632
+ "marker.dead": "워커 사라짐 · 종료 코드 미기록",
1633
+ "marker.invalid": "잘못된 디스패치 · 안전한 항목만",
1634
+ "marker.detailUnavailable": "상세 정보를 일시적으로 볼 수 없습니다",
1635
+ "error.taskPreserved": "상세 정보를 다시 불러오는 동안 마지막 작업 스냅샷을 유지합니다.",
1636
+ "error.reconnecting": "로컬 작업 보드에 다시 연결하는 중입니다.",
1637
+ "jobState.starting": "시작 중",
1638
+ "jobState.running": "실행 중",
1639
+ "jobState.succeeded": "성공",
1640
+ "jobState.failed": "실패",
1641
+ "jobState.dead": "사라짐",
1642
+ "jobState.invalid": "잘못됨",
1643
+ "noscript": "이 로컬 보드를 읽으려면 JavaScript가 필요합니다.",
1644
+ },
1645
+ "zh-TW": {
1646
+ "app.title": "Omnilane 即時看板",
1647
+ "header.liveJobs": "進行中的工作",
1648
+ "header.readOnly": "唯讀",
1649
+ "header.language": "語言",
1650
+ "connection.connecting": "連線中",
1651
+ "connection.live": "本機訊號即時連線",
1652
+ "connection.reconnecting": "重新連線中",
1653
+ "connection.unauthorized": "未授權",
1654
+ "index.sectionLabel": "最近的工作",
1655
+ "index.title": "任務紀錄",
1656
+ "search.label": "尋找任務",
1657
+ "search.placeholder": "任務、ID、車道、模型",
1658
+ "filter.legend": "顯示",
1659
+ "filter.all": "全部",
1660
+ "filter.active": "進行中",
1661
+ "filter.done": "完成",
1662
+ "filter.issues": "有問題",
1663
+ "list.ariaLabel": "最近的任務",
1664
+ "list.waiting": "等待任務中。",
1665
+ "list.empty": "尚無任務。",
1666
+ "list.unauthorized": "需要新的本機連結。",
1667
+ "list.noMatch": "沒有符合此篩選的任務。",
1668
+ "count.jobs": { other: "{n} 筆工作" },
1669
+ "count.filtered": "{shown} / {total} 筆工作",
1670
+ "inspector.ariaLabel": "所選任務的詳細內容",
1671
+ "state.local.code": "本機 · 唯讀",
1672
+ "state.connecting.title": "正在連線到工作看板",
1673
+ "state.connecting.message": "最新的任務會顯示在這裡。",
1674
+ "state.unauthorized.title": "需要本機存取權",
1675
+ "state.unauthorized.message": "執行 `omnilane ui url` 取得新的本機連結。",
1676
+ "state.empty.code": "佇列 · 空的",
1677
+ "state.empty.title": "尚無任務",
1678
+ "state.empty.message": "執行一個 Omnilane 任務,此看板會自動更新。",
1679
+ "state.noMatch.code": "篩選 · 無相符",
1680
+ "state.noMatch.title": "沒有相符的任務",
1681
+ "state.noMatch.message": "清除搜尋條件或改選其他狀態。",
1682
+ "state.retry.code": "連線 · 重試中",
1683
+ "state.retry.title": "本機看板無法使用",
1684
+ "state.retry.message": "正在重新連線到本機工作看板。",
1685
+ "mobile.back": "返回任務清單",
1686
+ "detail.sectionLabel": "所選任務",
1687
+ "detail.timeStarted": "開始於 {value}",
1688
+ "detail.timeUnavailable": "時間不明",
1689
+ "detail.timeUnknown": "開始時間不明",
1690
+ "compare.pin": "釘選以比較",
1691
+ "compare.unpin": "取消釘選",
1692
+ "compare.replace": "更換參考",
1693
+ "compare.none": "未釘選參考",
1694
+ "compare.pinned": "已釘選參考 {id}",
1695
+ "compare.sectionLabel": "參考快照",
1696
+ "compare.heading": "路由與結果比較",
1697
+ "compare.clear": "清除參考",
1698
+ "compare.roleReference": "已釘選的參考",
1699
+ "compare.roleCurrent": "目前選取",
1700
+ "compare.outputLabel": "公開結果",
1701
+ "field.lane": "車道",
1702
+ "field.vendor": "廠商",
1703
+ "field.model": "模型",
1704
+ "field.state": "狀態",
1705
+ "field.effort": "運算力度",
1706
+ "field.mode": "模式",
1707
+ "field.watchdog": "看門狗",
1708
+ "field.candidate": "候選",
1709
+ "field.started": "開始時間",
1710
+ "field.workdir": "工作目錄",
1711
+ "task.sectionLabel": "任務",
1712
+ "task.heading": "詢問模型的內容",
1713
+ "output.sectionLabel": "輸出",
1714
+ "output.heading": "公開結果",
1715
+ "routing.sectionLabel": "路由",
1716
+ "routing.heading": "模型路徑",
1717
+ "meta.summary": "技術中繼資料",
1718
+ "meta.ariaLabel": "任務中繼資料",
1719
+ "value.unknown": "不明",
1720
+ "value.notRecorded": "未記錄",
1721
+ "value.unknownLane": "車道不明",
1722
+ "value.unknownVendor": "廠商不明",
1723
+ "value.unknownModel": "模型不明",
1724
+ "value.seconds": "{n} 秒",
1725
+ "loading.task": "載入任務中…",
1726
+ "loading.output": "載入輸出中…",
1727
+ "loading.running": "工作端執行中,等待輸出…",
1728
+ "result.starting": "派工正在啟動,尚未記錄公開結果。",
1729
+ "result.running": "工作端執行中,公開結果會顯示在這裡。",
1730
+ "result.failedCode": "派工失敗,結束代碼 {code}。沒有記錄公開結果。",
1731
+ "result.failedUnknown": "派工失敗,結束代碼不明。沒有記錄公開結果。",
1732
+ "result.dead": "工作端已消失,且沒有記錄結束代碼。",
1733
+ "result.invalid": "這筆派工含有無效的中繼資料或控制檔,沒有可安全顯示的結果。",
1734
+ "result.none": "派工完成,但沒有公開結果。",
1735
+ "result.unsafe": "無法安全讀取公開結果。",
1736
+ "task.unsafe": "無法安全讀取任務內容。",
1737
+ "task.none": "沒有記錄任務內容。",
1738
+ "marker.truncated": "過長內容已截短為 512 KiB",
1739
+ "marker.taskUnsafe": "無法安全讀取任務檔",
1740
+ "marker.resultUnsafe": "無法安全讀取結果檔",
1741
+ "marker.failedNoCode": "派工失敗,且讀不到結束代碼",
1742
+ "marker.failedCode": "派工失敗,結束代碼 {code}",
1743
+ "marker.dead": "工作端消失 · 未記錄結束代碼",
1744
+ "marker.invalid": "無效派工 · 僅顯示安全欄位",
1745
+ "marker.detailUnavailable": "暫時無法取得詳細內容",
1746
+ "error.taskPreserved": "重新載入詳細內容時,保留上一份任務快照。",
1747
+ "error.reconnecting": "正在重新連線到本機工作看板。",
1748
+ "jobState.starting": "啟動中",
1749
+ "jobState.running": "執行中",
1750
+ "jobState.succeeded": "成功",
1751
+ "jobState.failed": "失敗",
1752
+ "jobState.dead": "消失",
1753
+ "jobState.invalid": "無效",
1754
+ "noscript": "需要 JavaScript 才能閱讀這個本機看板。",
1755
+ },
1756
+ "zh-CN": {
1757
+ "app.title": "Omnilane 实时看板",
1758
+ "header.liveJobs": "进行中的作业",
1759
+ "header.readOnly": "只读",
1760
+ "header.language": "语言",
1761
+ "connection.connecting": "连接中",
1762
+ "connection.live": "本地信号实时连接",
1763
+ "connection.reconnecting": "重新连接中",
1764
+ "connection.unauthorized": "未授权",
1765
+ "index.sectionLabel": "最近的工作",
1766
+ "index.title": "任务记录",
1767
+ "search.label": "查找任务",
1768
+ "search.placeholder": "任务、ID、车道、模型",
1769
+ "filter.legend": "显示",
1770
+ "filter.all": "全部",
1771
+ "filter.active": "进行中",
1772
+ "filter.done": "完成",
1773
+ "filter.issues": "有问题",
1774
+ "list.ariaLabel": "最近的任务",
1775
+ "list.waiting": "等待任务中。",
1776
+ "list.empty": "暂无任务。",
1777
+ "list.unauthorized": "需要新的本地链接。",
1778
+ "list.noMatch": "没有符合此筛选的任务。",
1779
+ "count.jobs": { other: "{n} 个作业" },
1780
+ "count.filtered": "{shown} / {total} 个作业",
1781
+ "inspector.ariaLabel": "所选任务的详细内容",
1782
+ "state.local.code": "本地 · 只读",
1783
+ "state.connecting.title": "正在连接到作业看板",
1784
+ "state.connecting.message": "最新的任务会显示在这里。",
1785
+ "state.unauthorized.title": "需要本地访问权限",
1786
+ "state.unauthorized.message": "运行 `omnilane ui url` 获取新的本地链接。",
1787
+ "state.empty.code": "队列 · 空",
1788
+ "state.empty.title": "暂无任务",
1789
+ "state.empty.message": "运行一个 Omnilane 任务,此看板会自动更新。",
1790
+ "state.noMatch.code": "筛选 · 无匹配",
1791
+ "state.noMatch.title": "没有匹配的任务",
1792
+ "state.noMatch.message": "清除搜索条件或改选其他状态。",
1793
+ "state.retry.code": "连接 · 重试中",
1794
+ "state.retry.title": "本地看板不可用",
1795
+ "state.retry.message": "正在重新连接到本地作业看板。",
1796
+ "mobile.back": "返回任务列表",
1797
+ "detail.sectionLabel": "所选任务",
1798
+ "detail.timeStarted": "开始于 {value}",
1799
+ "detail.timeUnavailable": "时间不详",
1800
+ "detail.timeUnknown": "开始时间不详",
1801
+ "compare.pin": "固定以比较",
1802
+ "compare.unpin": "取消固定",
1803
+ "compare.replace": "更换参考",
1804
+ "compare.none": "未固定参考",
1805
+ "compare.pinned": "已固定参考 {id}",
1806
+ "compare.sectionLabel": "参考快照",
1807
+ "compare.heading": "路由与结果比较",
1808
+ "compare.clear": "清除参考",
1809
+ "compare.roleReference": "已固定的参考",
1810
+ "compare.roleCurrent": "当前选择",
1811
+ "compare.outputLabel": "公开结果",
1812
+ "field.lane": "车道",
1813
+ "field.vendor": "厂商",
1814
+ "field.model": "模型",
1815
+ "field.state": "状态",
1816
+ "field.effort": "算力档位",
1817
+ "field.mode": "模式",
1818
+ "field.watchdog": "看门狗",
1819
+ "field.candidate": "候选",
1820
+ "field.started": "开始时间",
1821
+ "field.workdir": "工作目录",
1822
+ "task.sectionLabel": "任务",
1823
+ "task.heading": "向模型提出的内容",
1824
+ "output.sectionLabel": "输出",
1825
+ "output.heading": "公开结果",
1826
+ "routing.sectionLabel": "路由",
1827
+ "routing.heading": "模型路径",
1828
+ "meta.summary": "技术元数据",
1829
+ "meta.ariaLabel": "任务元数据",
1830
+ "value.unknown": "未知",
1831
+ "value.notRecorded": "未记录",
1832
+ "value.unknownLane": "车道未知",
1833
+ "value.unknownVendor": "厂商未知",
1834
+ "value.unknownModel": "模型未知",
1835
+ "value.seconds": "{n} 秒",
1836
+ "loading.task": "正在加载任务…",
1837
+ "loading.output": "正在加载输出…",
1838
+ "loading.running": "工作进程运行中,等待输出…",
1839
+ "result.starting": "派发正在启动,尚未记录公开结果。",
1840
+ "result.running": "工作进程运行中,公开结果会显示在这里。",
1841
+ "result.failedCode": "派发失败,退出码 {code}。未记录公开结果。",
1842
+ "result.failedUnknown": "派发失败,退出码未知。未记录公开结果。",
1843
+ "result.dead": "工作进程已消失,且未记录退出码。",
1844
+ "result.invalid": "这条派发含有无效的元数据或控制文件,没有可安全显示的结果。",
1845
+ "result.none": "派发完成,但没有公开结果。",
1846
+ "result.unsafe": "无法安全读取公开结果。",
1847
+ "task.unsafe": "无法安全读取任务内容。",
1848
+ "task.none": "未记录任务内容。",
1849
+ "marker.truncated": "过长内容已截断为 512 KiB",
1850
+ "marker.taskUnsafe": "无法安全读取任务文件",
1851
+ "marker.resultUnsafe": "无法安全读取结果文件",
1852
+ "marker.failedNoCode": "派发失败,且读不到退出码",
1853
+ "marker.failedCode": "派发失败,退出码 {code}",
1854
+ "marker.dead": "工作进程消失 · 未记录退出码",
1855
+ "marker.invalid": "无效派发 · 仅显示安全字段",
1856
+ "marker.detailUnavailable": "暂时无法获取详细内容",
1857
+ "error.taskPreserved": "重新加载详细内容时,保留上一份任务快照。",
1858
+ "error.reconnecting": "正在重新连接到本地作业看板。",
1859
+ "jobState.starting": "启动中",
1860
+ "jobState.running": "运行中",
1861
+ "jobState.succeeded": "成功",
1862
+ "jobState.failed": "失败",
1863
+ "jobState.dead": "消失",
1864
+ "jobState.invalid": "无效",
1865
+ "noscript": "需要 JavaScript 才能查看这个本地看板。",
1866
+ },
1867
+ };
1868
+
1091
1869
  function start() {
1870
+ initializeLanguage();
1092
1871
  initializeHistory();
1093
1872
  bindControls();
1094
1873
  if (!state.token) {
@@ -1097,7 +1876,7 @@
1097
1876
  }
1098
1877
 
1099
1878
  setControlsDisabled(false);
1100
- setConnection("waiting", "Connecting");
1879
+ setConnection("waiting", "connection.connecting");
1101
1880
  openEventStream();
1102
1881
  loadInitialSnapshot();
1103
1882
  }