@evanbobo/colco4dsh 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/README.md +54 -2
- package/client.js +755 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +5 -0
- package/index.mjs +39 -0
- package/lib/cli.mjs +134 -0
- package/lib/dashboard.mjs +109 -0
- package/lib/format.mjs +174 -0
- package/lib/plugin.mjs +158 -0
- package/lib/routes.mjs +424 -0
- package/lib/skill.mjs +43 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
package/client.js
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
// colco4dsh browser half: the "Colco" row in DSH's left sidebar, above More, and the page it opens in the main
|
|
2
|
+
// column. DSH loads this file as a classic script from exports["./client"]; it only registers a lazy factory, and
|
|
3
|
+
// React comes from DSH's shared module table. The page talks to the Host routes in lib/routes.mjs and nothing
|
|
4
|
+
// else; it never runs git or reaches the Colco server.
|
|
5
|
+
window.__ModuleLoader__.load({
|
|
6
|
+
id: "@evanbobo/colco4dsh",
|
|
7
|
+
factory(require) {
|
|
8
|
+
const React = require("react");
|
|
9
|
+
const { createElement: h, Fragment, useCallback, useEffect, useRef, useState } = React;
|
|
10
|
+
|
|
11
|
+
/** Base path of the Host routes. DSH serves plugin bundles under /plugins; this longer prefix belongs to us. */
|
|
12
|
+
const API = "/plugins/colco4dsh/api";
|
|
13
|
+
/** Header every POST carries so a cross-site form cannot trigger an action. */
|
|
14
|
+
const ACTION_HEADER = "x-colco4dsh";
|
|
15
|
+
/** Id of the left-sidebar row and key of the main panel it opens. */
|
|
16
|
+
const PANEL_ID = "colco";
|
|
17
|
+
/** Row position in the left sidebar: after Plugins (0) and Automation tasks (10), so directly above More. */
|
|
18
|
+
const PANEL_ORDER = 20;
|
|
19
|
+
/** Locale namespace of this plugin's copy. */
|
|
20
|
+
const NS = "colco4dsh";
|
|
21
|
+
/** How often status, runs and decisions refresh while the tab is visible. */
|
|
22
|
+
const POLL_MS = 10000;
|
|
23
|
+
/** How long one request may take before the tab gives up on it. */
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 65000;
|
|
25
|
+
|
|
26
|
+
/** English copy. Chinese has the same keys; it is written with \u escapes so the repository's English-only check, which scans this file, stays green. */
|
|
27
|
+
const en = {
|
|
28
|
+
"panel.title": "Colco",
|
|
29
|
+
"panel.description": "Runner, active runs and open decisions",
|
|
30
|
+
"runner.title": "Runner",
|
|
31
|
+
"state.running": "Running",
|
|
32
|
+
"state.stopped": "Stopped",
|
|
33
|
+
"state.not_configured": "Not configured",
|
|
34
|
+
"state.rules_error": "Rules error",
|
|
35
|
+
"state.unknown": "Unknown",
|
|
36
|
+
"state.loading": "Loading",
|
|
37
|
+
"runner.steps": "{busy} of {concurrency} steps running",
|
|
38
|
+
"runner.stepsNow": "{busy} steps running",
|
|
39
|
+
"runner.name": "Runner {name}, version {version}",
|
|
40
|
+
"runner.start": "Start",
|
|
41
|
+
"runner.stop": "Stop",
|
|
42
|
+
"runner.refresh": "Refresh",
|
|
43
|
+
"runner.editRules": "Edit rules",
|
|
44
|
+
"runner.startHint": "Start installs the runner as a login service; Stop stops it.",
|
|
45
|
+
"runner.notConfigured": "No runner is set up on this computer. Log in with colco add, run colco runner init in a terminal, then finish the setup below.",
|
|
46
|
+
"runner.updated": "Updated {time}",
|
|
47
|
+
"error.section": "Could not load {section}: {message}",
|
|
48
|
+
"section.runner": "the runner",
|
|
49
|
+
"section.runs": "runs",
|
|
50
|
+
"section.decisions": "decisions",
|
|
51
|
+
"runs.title": "Active runs",
|
|
52
|
+
"runs.empty": "No active runs.",
|
|
53
|
+
"phase.queued": "Queued",
|
|
54
|
+
"phase.running": "Running",
|
|
55
|
+
"phase.waiting": "Waiting for you",
|
|
56
|
+
"phase.uncertain": "Uncertain",
|
|
57
|
+
"run.meta": "{workflow} / {step}",
|
|
58
|
+
"run.agent": "Agent {agent}",
|
|
59
|
+
"run.for": "Running for {duration}",
|
|
60
|
+
"run.openTask": "Open task",
|
|
61
|
+
"run.openResult": "Open merge request",
|
|
62
|
+
"run.task": "Task #{id}",
|
|
63
|
+
"duration.s": "{n}s",
|
|
64
|
+
"duration.m": "{n}m",
|
|
65
|
+
"duration.h": "{h}h {m}m",
|
|
66
|
+
"duration.d": "{d}d {h}h",
|
|
67
|
+
"decisions.title": "Decisions",
|
|
68
|
+
"decisions.empty": "Nothing is waiting for your decision.",
|
|
69
|
+
"decisions.recommended": "Recommended",
|
|
70
|
+
"decisions.note": "Note (optional)",
|
|
71
|
+
"decisions.answer": "Answer",
|
|
72
|
+
"decisions.answering": "Answering",
|
|
73
|
+
"decisions.pick": "Choose an option first.",
|
|
74
|
+
"decisions.open": "Open in Colco",
|
|
75
|
+
"setup.title": "Runner setup",
|
|
76
|
+
"setup.loading": "Loading the rules",
|
|
77
|
+
"setup.missing": "There is no rules file yet. Run colco runner init in a terminal to create it from the template, then open this setup again.",
|
|
78
|
+
"setup.unreadable": "The rules file at {path} is not a JSON object. Fix or remove it in an editor; saving here would lose its agents and workflows.",
|
|
79
|
+
"setup.invalid": "The rules on disk have these errors:",
|
|
80
|
+
"setup.optionsFailed": "Could not load labels and repositories: {message}",
|
|
81
|
+
"setup.labels": "Labels",
|
|
82
|
+
"setup.labelsHint": "Tasks with a label run its workflow.",
|
|
83
|
+
"setup.label": "Label",
|
|
84
|
+
"setup.workflow": "Workflow",
|
|
85
|
+
"setup.chooseLabel": "Choose a label",
|
|
86
|
+
"setup.chooseWorkflow": "Choose a workflow",
|
|
87
|
+
"setup.addLabel": "Add label",
|
|
88
|
+
"setup.remove": "Remove",
|
|
89
|
+
"setup.folders": "Folders",
|
|
90
|
+
"setup.foldersHint": "The git repositories the runner may work in.",
|
|
91
|
+
"setup.path": "Folder path",
|
|
92
|
+
"setup.addFolder": "Add folder",
|
|
93
|
+
"setup.ok": "Git repository, origin {origin}",
|
|
94
|
+
"setup.unchecked": "Not checked yet; saving checks it.",
|
|
95
|
+
"setup.missingFolder": "The folder does not exist.",
|
|
96
|
+
"setup.notGit": "The folder is not a git repository.",
|
|
97
|
+
"setup.noOrigin": "The repository has no origin remote.",
|
|
98
|
+
"setup.repository": "Colco repository",
|
|
99
|
+
"setup.chooseRepository": "Choose the repository for the fix",
|
|
100
|
+
"setup.fix": "Run in a terminal:",
|
|
101
|
+
"setup.copy": "Copy",
|
|
102
|
+
"setup.copied": "Copied",
|
|
103
|
+
"setup.save": "Save rules",
|
|
104
|
+
"setup.saving": "Saving",
|
|
105
|
+
"setup.cancel": "Close",
|
|
106
|
+
"setup.saved": "Saved {path}",
|
|
107
|
+
"setup.savedBackup": "Saved {path}; the previous file is at {backup}",
|
|
108
|
+
"setup.saveFailed": "The rules were not saved: {message}",
|
|
109
|
+
"setup.duplicateLabel": "The label {label} appears twice.",
|
|
110
|
+
"setup.incomplete": "Every label row needs both a label and a workflow.",
|
|
111
|
+
"setup.notOnServer": "{label} (not on the server)",
|
|
112
|
+
};
|
|
113
|
+
const zh = {
|
|
114
|
+
"panel.title": "Colco",
|
|
115
|
+
"panel.description": "Runner\u3001\u8fdb\u884c\u4e2d\u7684\u8fd0\u884c\u548c\u5f85\u51b3\u7b56",
|
|
116
|
+
"runner.title": "Runner",
|
|
117
|
+
"state.running": "\u8fd0\u884c\u4e2d",
|
|
118
|
+
"state.stopped": "\u5df2\u505c\u6b62",
|
|
119
|
+
"state.not_configured": "\u672a\u914d\u7f6e",
|
|
120
|
+
"state.rules_error": "\u89c4\u5219\u6709\u8bef",
|
|
121
|
+
"state.unknown": "\u672a\u77e5",
|
|
122
|
+
"state.loading": "\u52a0\u8f7d\u4e2d",
|
|
123
|
+
"runner.steps": "{busy}/{concurrency} \u4e2a\u6b65\u9aa4\u6b63\u5728\u8fd0\u884c",
|
|
124
|
+
"runner.stepsNow": "{busy} \u4e2a\u6b65\u9aa4\u6b63\u5728\u8fd0\u884c",
|
|
125
|
+
"runner.name": "Runner {name}\uff0c\u7248\u672c {version}",
|
|
126
|
+
"runner.start": "\u542f\u52a8",
|
|
127
|
+
"runner.stop": "\u505c\u6b62",
|
|
128
|
+
"runner.refresh": "\u5237\u65b0",
|
|
129
|
+
"runner.editRules": "\u7f16\u8f91\u89c4\u5219",
|
|
130
|
+
"runner.startHint": "\u542f\u52a8\u4f1a\u628a Runner \u5b89\u88c5\u4e3a\u767b\u5f55\u670d\u52a1\uff1b\u505c\u6b62\u4f1a\u505c\u6389\u5b83\u3002",
|
|
131
|
+
"runner.notConfigured": "\u8fd9\u53f0\u7535\u8111\u8fd8\u6ca1\u6709\u8bbe\u7f6e Runner\u3002\u8bf7\u5148\u7528 colco add \u767b\u5f55\uff0c\u518d\u5728\u7ec8\u7aef\u8fd0\u884c colco runner init\uff0c\u7136\u540e\u5b8c\u6210\u4e0b\u9762\u7684\u8bbe\u7f6e\u3002",
|
|
132
|
+
"runner.updated": "\u66f4\u65b0\u4e8e {time}",
|
|
133
|
+
"error.section": "\u65e0\u6cd5\u52a0\u8f7d{section}\uff1a{message}",
|
|
134
|
+
"section.runner": "Runner",
|
|
135
|
+
"section.runs": "\u8fd0\u884c",
|
|
136
|
+
"section.decisions": "\u51b3\u7b56",
|
|
137
|
+
"runs.title": "\u8fdb\u884c\u4e2d\u7684\u8fd0\u884c",
|
|
138
|
+
"runs.empty": "\u6ca1\u6709\u8fdb\u884c\u4e2d\u7684\u8fd0\u884c\u3002",
|
|
139
|
+
"phase.queued": "\u6392\u961f\u4e2d",
|
|
140
|
+
"phase.running": "\u8fd0\u884c\u4e2d",
|
|
141
|
+
"phase.waiting": "\u7b49\u4f60\u51b3\u5b9a",
|
|
142
|
+
"phase.uncertain": "\u72b6\u6001\u4e0d\u660e",
|
|
143
|
+
"run.meta": "{workflow} / {step}",
|
|
144
|
+
"run.agent": "Agent {agent}",
|
|
145
|
+
"run.for": "\u5df2\u8fd0\u884c {duration}",
|
|
146
|
+
"run.openTask": "\u6253\u5f00\u4efb\u52a1",
|
|
147
|
+
"run.openResult": "\u6253\u5f00\u5408\u5e76\u8bf7\u6c42",
|
|
148
|
+
"run.task": "\u4efb\u52a1 #{id}",
|
|
149
|
+
"duration.s": "{n} \u79d2",
|
|
150
|
+
"duration.m": "{n} \u5206\u949f",
|
|
151
|
+
"duration.h": "{h} \u5c0f\u65f6 {m} \u5206\u949f",
|
|
152
|
+
"duration.d": "{d} \u5929 {h} \u5c0f\u65f6",
|
|
153
|
+
"decisions.title": "\u51b3\u7b56",
|
|
154
|
+
"decisions.empty": "\u6ca1\u6709\u7b49\u4f60\u51b3\u5b9a\u7684\u4e8b\u3002",
|
|
155
|
+
"decisions.recommended": "\u63a8\u8350",
|
|
156
|
+
"decisions.note": "\u5907\u6ce8\uff08\u53ef\u9009\uff09",
|
|
157
|
+
"decisions.answer": "\u56de\u7b54",
|
|
158
|
+
"decisions.answering": "\u6b63\u5728\u56de\u7b54",
|
|
159
|
+
"decisions.pick": "\u8bf7\u5148\u9009\u62e9\u4e00\u4e2a\u9009\u9879\u3002",
|
|
160
|
+
"decisions.open": "\u5728 Colco \u4e2d\u6253\u5f00",
|
|
161
|
+
"setup.title": "Runner \u8bbe\u7f6e",
|
|
162
|
+
"setup.loading": "\u6b63\u5728\u52a0\u8f7d\u89c4\u5219",
|
|
163
|
+
"setup.missing": "\u8fd8\u6ca1\u6709\u89c4\u5219\u6587\u4ef6\u3002\u8bf7\u5728\u7ec8\u7aef\u8fd0\u884c colco runner init \u4ece\u6a21\u677f\u521b\u5efa\uff0c\u7136\u540e\u91cd\u65b0\u6253\u5f00\u6b64\u8bbe\u7f6e\u3002",
|
|
164
|
+
"setup.unreadable": "{path} \u5904\u7684\u89c4\u5219\u6587\u4ef6\u4e0d\u662f JSON \u5bf9\u8c61\u3002\u8bf7\u5728\u7f16\u8f91\u5668\u4e2d\u4fee\u590d\u6216\u5220\u9664\u5b83\uff1b\u5728\u8fd9\u91cc\u4fdd\u5b58\u4f1a\u4e22\u5931\u5176\u4e2d\u7684 agents \u548c workflows\u3002",
|
|
165
|
+
"setup.invalid": "\u78c1\u76d8\u4e0a\u7684\u89c4\u5219\u6709\u4ee5\u4e0b\u9519\u8bef\uff1a",
|
|
166
|
+
"setup.optionsFailed": "\u65e0\u6cd5\u52a0\u8f7d\u6807\u7b7e\u548c\u4ed3\u5e93\uff1a{message}",
|
|
167
|
+
"setup.labels": "\u6807\u7b7e",
|
|
168
|
+
"setup.labelsHint": "\u5e26\u6709\u8be5\u6807\u7b7e\u7684\u4efb\u52a1\u4f1a\u8fd0\u884c\u5bf9\u5e94\u7684\u5de5\u4f5c\u6d41\u3002",
|
|
169
|
+
"setup.label": "\u6807\u7b7e",
|
|
170
|
+
"setup.workflow": "\u5de5\u4f5c\u6d41",
|
|
171
|
+
"setup.chooseLabel": "\u9009\u62e9\u6807\u7b7e",
|
|
172
|
+
"setup.chooseWorkflow": "\u9009\u62e9\u5de5\u4f5c\u6d41",
|
|
173
|
+
"setup.addLabel": "\u6dfb\u52a0\u6807\u7b7e",
|
|
174
|
+
"setup.remove": "\u79fb\u9664",
|
|
175
|
+
"setup.folders": "\u6587\u4ef6\u5939",
|
|
176
|
+
"setup.foldersHint": "Runner \u53ef\u4ee5\u5de5\u4f5c\u7684 git \u4ed3\u5e93\u76ee\u5f55\u3002",
|
|
177
|
+
"setup.path": "\u6587\u4ef6\u5939\u8def\u5f84",
|
|
178
|
+
"setup.addFolder": "\u6dfb\u52a0\u6587\u4ef6\u5939",
|
|
179
|
+
"setup.ok": "git \u4ed3\u5e93\uff0corigin \u4e3a {origin}",
|
|
180
|
+
"setup.unchecked": "\u5c1a\u672a\u68c0\u67e5\uff1b\u4fdd\u5b58\u540e\u4f1a\u68c0\u67e5\u3002",
|
|
181
|
+
"setup.missingFolder": "\u6587\u4ef6\u5939\u4e0d\u5b58\u5728\u3002",
|
|
182
|
+
"setup.notGit": "\u8be5\u6587\u4ef6\u5939\u4e0d\u662f git \u4ed3\u5e93\u3002",
|
|
183
|
+
"setup.noOrigin": "\u8be5\u4ed3\u5e93\u6ca1\u6709 origin remote\u3002",
|
|
184
|
+
"setup.repository": "Colco \u4ed3\u5e93",
|
|
185
|
+
"setup.chooseRepository": "\u9009\u62e9\u7528\u4e8e\u4fee\u590d\u7684\u4ed3\u5e93",
|
|
186
|
+
"setup.fix": "\u5728\u7ec8\u7aef\u8fd0\u884c\uff1a",
|
|
187
|
+
"setup.copy": "\u590d\u5236",
|
|
188
|
+
"setup.copied": "\u5df2\u590d\u5236",
|
|
189
|
+
"setup.save": "\u4fdd\u5b58\u89c4\u5219",
|
|
190
|
+
"setup.saving": "\u6b63\u5728\u4fdd\u5b58",
|
|
191
|
+
"setup.cancel": "\u5173\u95ed",
|
|
192
|
+
"setup.saved": "\u5df2\u4fdd\u5b58 {path}",
|
|
193
|
+
"setup.savedBackup": "\u5df2\u4fdd\u5b58 {path}\uff1b\u539f\u6587\u4ef6\u5728 {backup}",
|
|
194
|
+
"setup.saveFailed": "\u89c4\u5219\u672a\u4fdd\u5b58\uff1a{message}",
|
|
195
|
+
"setup.duplicateLabel": "\u6807\u7b7e {label} \u51fa\u73b0\u4e86\u4e24\u6b21\u3002",
|
|
196
|
+
"setup.incomplete": "\u6bcf\u4e00\u884c\u6807\u7b7e\u90fd\u9700\u8981\u540c\u65f6\u9009\u62e9\u6807\u7b7e\u548c\u5de5\u4f5c\u6d41\u3002",
|
|
197
|
+
"setup.notOnServer": "{label}\uff08\u670d\u52a1\u5668\u4e0a\u6ca1\u6709\uff09",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Fill `{name}` placeholders the way DSH's locale service does.
|
|
202
|
+
* @param {string} template
|
|
203
|
+
* @param {Record<string, unknown> | undefined} params
|
|
204
|
+
*/
|
|
205
|
+
function fill(template, params) {
|
|
206
|
+
return params ? template.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : template;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A translate function over one dictionary, for when DSH's locale service is absent (tests, other hosts).
|
|
211
|
+
* @param {Record<string, string>} dict
|
|
212
|
+
* @returns {(key: string, params?: Record<string, unknown>) => string}
|
|
213
|
+
*/
|
|
214
|
+
function translator(dict) {
|
|
215
|
+
return (key, params) => fill(dict[key] ?? en[key] ?? key, params);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A failed route call: its HTTP status, its message and any validation errors the route returned. */
|
|
219
|
+
class ApiError extends Error {
|
|
220
|
+
constructor(message, status, errors) {
|
|
221
|
+
super(message);
|
|
222
|
+
this.status = status;
|
|
223
|
+
this.errors = errors;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Call one Host route and return its JSON reply.
|
|
229
|
+
* @param {string} name - route name, e.g. "status".
|
|
230
|
+
* @param {{ method?: "GET" | "POST", body?: unknown, signal?: AbortSignal }} [options]
|
|
231
|
+
*/
|
|
232
|
+
async function call(name, { method = "GET", body, signal } = {}) {
|
|
233
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
234
|
+
const headers = { accept: "application/json" };
|
|
235
|
+
if (method === "POST") Object.assign(headers, { "content-type": "application/json", [ACTION_HEADER]: "1" });
|
|
236
|
+
const response = await fetch(`${API}/${name}`, {
|
|
237
|
+
method, headers, credentials: "same-origin", cache: "no-store",
|
|
238
|
+
body: method === "POST" ? JSON.stringify(body ?? {}) : undefined,
|
|
239
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
240
|
+
});
|
|
241
|
+
let reply;
|
|
242
|
+
try {
|
|
243
|
+
reply = await response.json();
|
|
244
|
+
} catch {
|
|
245
|
+
reply = undefined;
|
|
246
|
+
}
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
const message = typeof reply?.error?.message === "string" ? reply.error.message : `HTTP ${response.status}`;
|
|
249
|
+
throw new ApiError(message, response.status, Array.isArray(reply?.errors) ? reply.errors.filter((item) => typeof item === "string") : []);
|
|
250
|
+
}
|
|
251
|
+
return reply ?? {};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Quote one shell word so a path or URL from Colco is copied as itself and runs nothing.
|
|
256
|
+
* @param {string} value
|
|
257
|
+
*/
|
|
258
|
+
function shellQuote(value) {
|
|
259
|
+
return /^[\w@%+=:,./-]+$/.test(value) && !value.startsWith("-") ? value : `'${value.replace(/'/g, "'\\''")}'`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The command that fixes a folder the runner cannot use, or undefined when there is nothing to fix.
|
|
264
|
+
* Built from the repository the user picked; the plugin only shows it and never runs it.
|
|
265
|
+
* @param {{ exists: boolean, git: boolean, origin: string | null, path: string, resolvedPath?: string }} check
|
|
266
|
+
* @param {string | undefined} url - clone URL of the chosen Colco repository.
|
|
267
|
+
*/
|
|
268
|
+
function fixCommand(check, url) {
|
|
269
|
+
if (!url || (check.exists && check.git && check.origin)) return undefined;
|
|
270
|
+
const path = shellQuote(check.resolvedPath || check.path);
|
|
271
|
+
const remote = shellQuote(url);
|
|
272
|
+
if (!check.exists) return `git clone -- ${remote} ${path}`;
|
|
273
|
+
if (!check.git) return `git -C ${path} init && git -C ${path} remote add origin ${remote}`;
|
|
274
|
+
return `git -C ${path} remote add origin ${remote}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A compact duration from milliseconds.
|
|
279
|
+
* @param {number} ms
|
|
280
|
+
* @param {(key: string, params?: Record<string, unknown>) => string} t
|
|
281
|
+
*/
|
|
282
|
+
function formatDuration(ms, t) {
|
|
283
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
284
|
+
if (seconds < 60) return t("duration.s", { n: seconds });
|
|
285
|
+
const minutes = Math.floor(seconds / 60);
|
|
286
|
+
if (minutes < 60) return t("duration.m", { n: minutes });
|
|
287
|
+
const hours = Math.floor(minutes / 60);
|
|
288
|
+
if (hours < 24) return t("duration.h", { h: hours, m: minutes % 60 });
|
|
289
|
+
return t("duration.d", { d: Math.floor(hours / 24), h: hours % 24 });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The editable rows of a rules object: label → workflow pairs and folders (with their on-disk index).
|
|
294
|
+
* @param {Record<string, unknown> | null} rules - the rules object on disk.
|
|
295
|
+
*/
|
|
296
|
+
function rulesToForm(rules) {
|
|
297
|
+
const labels = rules && rules.labels && typeof rules.labels === "object" && !Array.isArray(rules.labels)
|
|
298
|
+
? Object.entries(rules.labels).map(([label, workflow]) => ({ label, workflow: typeof workflow === "string" ? workflow : "" }))
|
|
299
|
+
: [];
|
|
300
|
+
const folders = rules && Array.isArray(rules.folders)
|
|
301
|
+
? rules.folders.map((folder, index) => ({ index, path: folder && typeof folder.path === "string" ? folder.path : "" }))
|
|
302
|
+
: [];
|
|
303
|
+
return { labels, folders };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The whole rules object to save: the one on disk with its labels and folders replaced by the form's rows.
|
|
308
|
+
* Agents, workflows, concurrency and each kept folder's other fields (such as `setup`) stay as they are.
|
|
309
|
+
* @param {Record<string, unknown> | null} rules - the rules object on disk, or null when there is none.
|
|
310
|
+
* @param {{ labels: { label: string, workflow: string }[], folders: { index?: number, path: string }[] }} form
|
|
311
|
+
* @returns {{ rules?: Record<string, unknown>, error?: { key: string, params?: Record<string, unknown> } }}
|
|
312
|
+
*/
|
|
313
|
+
function formToRules(rules, form) {
|
|
314
|
+
const labels = {};
|
|
315
|
+
for (const row of form.labels) {
|
|
316
|
+
const label = row.label.trim();
|
|
317
|
+
if (!label && !row.workflow) continue;
|
|
318
|
+
if (!label || !row.workflow) return { error: { key: "setup.incomplete" } };
|
|
319
|
+
if (Object.prototype.hasOwnProperty.call(labels, label)) return { error: { key: "setup.duplicateLabel", params: { label } } };
|
|
320
|
+
Object.defineProperty(labels, label, { value: row.workflow, enumerable: true, writable: true, configurable: true });
|
|
321
|
+
}
|
|
322
|
+
const onDisk = rules && Array.isArray(rules.folders) ? rules.folders : [];
|
|
323
|
+
const folders = form.folders.filter((row) => row.path.trim()).map((row) => {
|
|
324
|
+
const original = row.index !== undefined && onDisk[row.index] && typeof onDisk[row.index] === "object" ? onDisk[row.index] : {};
|
|
325
|
+
return { ...original, path: row.path.trim() };
|
|
326
|
+
});
|
|
327
|
+
return { rules: { ...(rules ?? {}), labels, folders } };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Page styles: DSH theme tokens with plain fallbacks, scoped to .c4d-page and .c4d and removed with the page. */
|
|
331
|
+
const CSS = `
|
|
332
|
+
.c4d-page{display:flex;flex-direction:column;flex:1;width:100%;height:100%;min-width:0;min-height:0;overflow:hidden;color:var(--dsw-alias-label-primary,#1f2328);background:var(--dsw-alias-bg-base,transparent)}
|
|
333
|
+
.c4d-page-scroll{flex:1;min-height:0;overflow:auto;scrollbar-gutter:stable}
|
|
334
|
+
.c4d-page-content{max-width:960px;margin:0 auto;padding:0 clamp(24px,4vw,48px) 48px}
|
|
335
|
+
.c4d-page-heading{display:flex;flex-direction:column;gap:4px;padding-top:28px;margin-bottom:8px}
|
|
336
|
+
.c4d-page-heading h1{margin:0;font-size:20px;line-height:28px;font-weight:500}
|
|
337
|
+
.c4d-page-heading p{margin:0;font-size:13px;color:var(--dsw-alias-label-secondary,#57606a)}
|
|
338
|
+
@media (max-width:767px){.c4d-page-content{padding-inline:20px}}
|
|
339
|
+
.c4d{display:flex;flex-direction:column;gap:16px;padding:12px 0 24px;font:var(--dsw-font-xs-13,13px/20px system-ui);color:var(--dsw-alias-label-primary,#1f2328)}
|
|
340
|
+
.c4d h2{margin:0;font-size:13px;font-weight:600}
|
|
341
|
+
.c4d h3{margin:0;font-size:13px;font-weight:600}
|
|
342
|
+
.c4d p{margin:0}
|
|
343
|
+
.c4d ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
|
|
344
|
+
.c4d-section{display:flex;flex-direction:column;gap:8px}
|
|
345
|
+
.c4d-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
|
346
|
+
.c4d-card{border:1px solid var(--dsw-alias-border-l2,#d0d7de);border-radius:var(--dsw-radius-md,12px);padding:10px 12px;display:flex;flex-direction:column;gap:6px;background:var(--dsw-alias-bg-layer-1,transparent)}
|
|
347
|
+
.c4d-muted{color:var(--dsw-alias-label-secondary,#57606a)}
|
|
348
|
+
.c4d-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--dsw-alias-label-tertiary,#8c959f)}
|
|
349
|
+
.c4d-dot[data-tone=ok]{background:var(--dsw-alias-state-success-primary,#1a7f37)}
|
|
350
|
+
.c4d-dot[data-tone=warn]{background:var(--dsw-alias-state-warn-primary,#9a6700)}
|
|
351
|
+
.c4d-dot[data-tone=error]{background:var(--dsw-alias-state-error-primary,#cf222e)}
|
|
352
|
+
.c4d-tag{display:inline-block;border:1px solid var(--dsw-alias-border-l2,#d0d7de);border-radius:6px;padding:0 6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#57606a)}
|
|
353
|
+
.c4d-tag[data-tone=warn]{color:var(--dsw-alias-state-warn-primary,#9a6700);border-color:currentColor}
|
|
354
|
+
.c4d-error{color:var(--dsw-alias-state-error-primary,#cf222e)}
|
|
355
|
+
.c4d-ok{color:var(--dsw-alias-state-success-primary,#1a7f37)}
|
|
356
|
+
.c4d button,.c4d select,.c4d input,.c4d textarea{font:inherit;color:inherit}
|
|
357
|
+
.c4d button{border:1px solid var(--dsw-alias-border-l2,#d0d7de);background:transparent;border-radius:8px;padding:3px 10px;cursor:pointer}
|
|
358
|
+
.c4d button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05))}
|
|
359
|
+
.c4d button:disabled{opacity:.5;cursor:default}
|
|
360
|
+
.c4d button[aria-pressed=true]{border-color:var(--dsw-alias-state-business-primary,#0969da);background:var(--dsw-alias-interactive-bg-hover,rgba(9,105,218,.08))}
|
|
361
|
+
.c4d button.c4d-primary{background:var(--dsw-alias-label-primary,#1f2328);color:var(--dsw-alias-label-primary-inverted,#fff);border-color:transparent}
|
|
362
|
+
.c4d select,.c4d input,.c4d textarea{border:1px solid var(--dsw-alias-border-l2,#d0d7de);border-radius:8px;padding:3px 8px;background:transparent;min-width:0}
|
|
363
|
+
.c4d textarea{width:100%;box-sizing:border-box;resize:vertical}
|
|
364
|
+
.c4d input.c4d-path{flex:1 1 160px}
|
|
365
|
+
.c4d :focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,#0969da);outline-offset:1px}
|
|
366
|
+
.c4d a{color:var(--dsw-alias-link,#0969da)}
|
|
367
|
+
.c4d code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;user-select:all;word-break:break-all;background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));border-radius:6px;padding:2px 6px}
|
|
368
|
+
.c4d-options{display:flex;flex-direction:column;align-items:stretch;gap:6px}
|
|
369
|
+
.c4d-options button{text-align:left}
|
|
370
|
+
`;
|
|
371
|
+
|
|
372
|
+
/** Tone of the runner's state dot. */
|
|
373
|
+
const STATE_TONE = { running: "ok", stopped: "idle", not_configured: "warn", rules_error: "error", unknown: "idle" };
|
|
374
|
+
|
|
375
|
+
/** One error message, announced to screen readers. */
|
|
376
|
+
function ErrorLine({ message }) {
|
|
377
|
+
return message ? h("p", { role: "alert", className: "c4d-error" }, message) : null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** A link to Colco or a merge request; only http(s) URLs become links. */
|
|
381
|
+
function ExternalLink({ href, children }) {
|
|
382
|
+
return typeof href === "string" && /^https?:\/\//i.test(href) ? h("a", { href, target: "_blank", rel: "noopener noreferrer" }, children) : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The header: runner state, steps running now, and Start, Stop, Refresh and Edit rules.
|
|
387
|
+
* @param {{ t: Function, runner: object | null | undefined, loading: boolean, busy: string | undefined, actionError: string | undefined, setupOpen: boolean, onAction: (name: "start" | "stop") => void, onRefresh: () => void, onEditRules: () => void, updatedAt: number | undefined }} props
|
|
388
|
+
*/
|
|
389
|
+
function RunnerHeader({ t, runner, loading, busy, actionError, setupOpen, onAction, onRefresh, onEditRules, updatedAt }) {
|
|
390
|
+
const display = runner ? runner.display : loading ? "loading" : "unknown";
|
|
391
|
+
const running = display === "running";
|
|
392
|
+
const steps = running && typeof runner.busy === "number"
|
|
393
|
+
? typeof runner.concurrency === "number" ? t("runner.steps", { busy: runner.busy, concurrency: runner.concurrency }) : t("runner.stepsNow", { busy: runner.busy })
|
|
394
|
+
: undefined;
|
|
395
|
+
return h("section", { className: "c4d-section", "aria-label": t("runner.title") },
|
|
396
|
+
h("div", { className: "c4d-row" },
|
|
397
|
+
h("span", { className: "c4d-dot", "data-tone": STATE_TONE[display] ?? "idle", "aria-hidden": true }),
|
|
398
|
+
h("h2", null, t("runner.title")),
|
|
399
|
+
h("span", { "data-colco4dsh-state": display }, t(`state.${display}`)),
|
|
400
|
+
steps ? h("span", { className: "c4d-muted" }, steps) : null),
|
|
401
|
+
runner && runner.name ? h("p", { className: "c4d-muted" }, t("runner.name", { name: runner.name, version: runner.version ?? "?" })) : null,
|
|
402
|
+
display === "not_configured" ? h("p", null, t("runner.notConfigured")) : null,
|
|
403
|
+
display === "rules_error" && runner.rulesError ? h("p", { className: "c4d-error" }, runner.rulesError) : null,
|
|
404
|
+
runner && Array.isArray(runner.warnings) ? runner.warnings.map((warning, index) => h("p", { key: index, className: "c4d-muted" }, warning)) : null,
|
|
405
|
+
h("div", { className: "c4d-row" },
|
|
406
|
+
h("button", { type: "button", className: "c4d-primary", disabled: busy !== undefined || running || display === "not_configured", "aria-busy": busy === "start", onClick: () => onAction("start"), title: t("runner.startHint") }, t("runner.start")),
|
|
407
|
+
h("button", { type: "button", disabled: busy !== undefined || display === "not_configured", "aria-busy": busy === "stop", onClick: () => onAction("stop") }, t("runner.stop")),
|
|
408
|
+
h("button", { type: "button", onClick: onRefresh }, t("runner.refresh")),
|
|
409
|
+
setupOpen ? null : h("button", { type: "button", onClick: onEditRules }, t("runner.editRules"))),
|
|
410
|
+
h(ErrorLine, { message: actionError }),
|
|
411
|
+
updatedAt ? h("p", { className: "c4d-muted" }, t("runner.updated", { time: new Date(updatedAt).toLocaleTimeString() })) : null);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* One active run.
|
|
416
|
+
* @param {{ t: Function, run: object, now: number }} props
|
|
417
|
+
*/
|
|
418
|
+
function RunCard({ t, run, now }) {
|
|
419
|
+
const started = run.createdAt ? Date.parse(run.createdAt) : NaN;
|
|
420
|
+
const meta = [run.workflow || run.step ? t("run.meta", { workflow: run.workflow ?? "?", step: run.step ?? "?" }) : "", run.agent ? t("run.agent", { agent: run.agent }) : "", Number.isFinite(started) ? t("run.for", { duration: formatDuration(now - started, t) }) : ""].filter(Boolean);
|
|
421
|
+
return h("li", { className: "c4d-card", "data-colco4dsh-run": run.id, "data-phase": run.phase },
|
|
422
|
+
h("div", { className: "c4d-row" },
|
|
423
|
+
h("h3", null, run.taskTitle || (run.taskId !== undefined ? t("run.task", { id: run.taskId }) : "")),
|
|
424
|
+
h("span", { className: "c4d-tag", "data-tone": run.phase === "waiting" || run.phase === "uncertain" ? "warn" : undefined }, t(`phase.${run.phase}`))),
|
|
425
|
+
meta.length ? h("p", { className: "c4d-muted" }, meta.join(" · ")) : null,
|
|
426
|
+
h("div", { className: "c4d-row" },
|
|
427
|
+
h(ExternalLink, { href: run.taskLink }, t("run.openTask")),
|
|
428
|
+
h(ExternalLink, { href: run.resultLink }, t("run.openResult"))));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* One open decision: its options in Colco's order with the recommended one marked (never moved), a note and Answer.
|
|
433
|
+
* @param {{ t: Function, decision: object, onAnswer: (id: number, index: number, text: string | undefined) => Promise<void> }} props
|
|
434
|
+
*/
|
|
435
|
+
function DecisionCard({ t, decision, onAnswer }) {
|
|
436
|
+
const [choice, setChoice] = useState(undefined);
|
|
437
|
+
const [note, setNote] = useState("");
|
|
438
|
+
const [pending, setPending] = useState(false);
|
|
439
|
+
const [error, setError] = useState(undefined);
|
|
440
|
+
const submit = async () => {
|
|
441
|
+
if (choice === undefined) {
|
|
442
|
+
setError(t("decisions.pick"));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
setPending(true);
|
|
446
|
+
setError(undefined);
|
|
447
|
+
try {
|
|
448
|
+
await onAnswer(decision.id, choice, note.trim() || undefined);
|
|
449
|
+
} catch (failure) {
|
|
450
|
+
setError(failure instanceof Error ? failure.message : String(failure));
|
|
451
|
+
} finally {
|
|
452
|
+
setPending(false);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
const task = decision.taskId !== undefined ? `${t("run.task", { id: decision.taskId })}${decision.taskTitle ? ` ${decision.taskTitle}` : ""}` : "";
|
|
456
|
+
return h("li", { className: "c4d-card", "data-colco4dsh-decision": decision.id },
|
|
457
|
+
h("h3", null, decision.title || `#${decision.id}`),
|
|
458
|
+
task ? h("p", { className: "c4d-muted" }, task) : null,
|
|
459
|
+
decision.text ? h("p", { style: { whiteSpace: "pre-wrap" } }, decision.text) : null,
|
|
460
|
+
h("div", { className: "c4d-options", role: "group", "aria-label": decision.title || `#${decision.id}` },
|
|
461
|
+
decision.options.map((option, index) => h("button", {
|
|
462
|
+
key: index, type: "button", "aria-pressed": choice === index, disabled: pending,
|
|
463
|
+
"data-colco4dsh-option": index, onClick: () => setChoice(choice === index ? undefined : index),
|
|
464
|
+
}, option || `#${index + 1}`, index === decision.recommend ? h(Fragment, null, " ", h("span", { className: "c4d-tag" }, t("decisions.recommended"))) : null))),
|
|
465
|
+
h("label", { className: "c4d-section" },
|
|
466
|
+
h("span", { className: "c4d-muted" }, t("decisions.note")),
|
|
467
|
+
h("textarea", { rows: 2, maxLength: 2000, value: note, disabled: pending, onChange: (event) => setNote(event.target.value) })),
|
|
468
|
+
h("div", { className: "c4d-row" },
|
|
469
|
+
h("button", { type: "button", className: "c4d-primary", disabled: pending, "aria-busy": pending, onClick: submit }, pending ? t("decisions.answering") : t("decisions.answer")),
|
|
470
|
+
h(ExternalLink, { href: decision.link }, t("decisions.open"))),
|
|
471
|
+
h(ErrorLine, { message: error }));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* One folder row: path, its check result and, when the runner cannot use it, a copyable fix built from the
|
|
476
|
+
* Colco repository picked in the row.
|
|
477
|
+
* @param {{ t: Function, row: { index?: number, path: string }, check: object | undefined, repositories: object[], repo: string, onRepo: (url: string) => void, onPath: (path: string) => void, onRemove: () => void }} props
|
|
478
|
+
*/
|
|
479
|
+
function FolderRow({ t, row, check, repositories, repo, onRepo, onPath, onRemove }) {
|
|
480
|
+
const [copied, setCopied] = useState(false);
|
|
481
|
+
const current = check && check.path === row.path ? check : undefined;
|
|
482
|
+
const broken = current && !(current.exists && current.git && current.origin);
|
|
483
|
+
const problem = !current ? t("setup.unchecked") : !current.exists ? t("setup.missingFolder") : !current.git ? t("setup.notGit") : !current.origin ? t("setup.noOrigin") : undefined;
|
|
484
|
+
const command = broken ? fixCommand(current, repo || undefined) : undefined;
|
|
485
|
+
const copy = async () => {
|
|
486
|
+
try {
|
|
487
|
+
await navigator.clipboard.writeText(command);
|
|
488
|
+
setCopied(true);
|
|
489
|
+
setTimeout(() => setCopied(false), 1500);
|
|
490
|
+
} catch {
|
|
491
|
+
setCopied(false);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
return h("li", { className: "c4d-card", "data-colco4dsh-folder": row.index ?? "new" },
|
|
495
|
+
h("div", { className: "c4d-row" },
|
|
496
|
+
h("input", { className: "c4d-path", "aria-label": t("setup.path"), value: row.path, spellCheck: false, onChange: (event) => onPath(event.target.value) }),
|
|
497
|
+
h("button", { type: "button", onClick: onRemove }, t("setup.remove"))),
|
|
498
|
+
problem
|
|
499
|
+
? h("p", { className: current ? "c4d-error" : "c4d-muted" }, problem, current && current.error ? ` (${current.error})` : "")
|
|
500
|
+
: h("p", { className: "c4d-ok" }, t("setup.ok", { origin: current.origin })),
|
|
501
|
+
broken ? h("label", { className: "c4d-row" },
|
|
502
|
+
h("span", { className: "c4d-muted" }, t("setup.repository")),
|
|
503
|
+
h("select", { value: repo, onChange: (event) => onRepo(event.target.value) },
|
|
504
|
+
h("option", { value: "" }, t("setup.chooseRepository")),
|
|
505
|
+
repositories.map((repository) => h("option", { key: repository.url, value: repository.url }, repository.name)))) : null,
|
|
506
|
+
command ? h("div", { className: "c4d-section" },
|
|
507
|
+
h("span", { className: "c4d-muted" }, t("setup.fix")),
|
|
508
|
+
h("code", { "data-colco4dsh-fix": true }, command),
|
|
509
|
+
h("div", { className: "c4d-row" }, h("button", { type: "button", onClick: copy }, copied ? t("setup.copied") : t("setup.copy")))) : null);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* The setup form over one loaded `GET rules` reply: label → workflow rows and folder rows. Save writes the whole
|
|
514
|
+
* rules object (agents, workflows and the rest as they are on disk) and shows the CLI's validation errors.
|
|
515
|
+
* @param {{ t: Function, data: object, options: object, optionsError: string | undefined, notice: string | undefined, canClose: boolean, onSave: (rules: object) => Promise<void>, onClose: () => void }} props
|
|
516
|
+
*/
|
|
517
|
+
function SetupForm({ t, data, options, optionsError, notice, canClose, onSave, onClose }) {
|
|
518
|
+
const [form, setForm] = useState(() => rulesToForm(data.rules));
|
|
519
|
+
const [repos, setRepos] = useState({});
|
|
520
|
+
const [saving, setSaving] = useState(false);
|
|
521
|
+
const [error, setError] = useState(undefined);
|
|
522
|
+
const [errors, setErrors] = useState([]);
|
|
523
|
+
const unreadable = data.exists && !data.rules;
|
|
524
|
+
const workflows = data.rules && data.rules.workflows && typeof data.rules.workflows === "object" ? Object.keys(data.rules.workflows) : [];
|
|
525
|
+
const serverLabels = options.labels.map((label) => label.name);
|
|
526
|
+
const checks = new Map(data.folders.map((folder) => [folder.index, folder]));
|
|
527
|
+
const setLabel = (index, patch) => setForm((prev) => ({ ...prev, labels: prev.labels.map((row, at) => (at === index ? { ...row, ...patch } : row)) }));
|
|
528
|
+
const setFolder = (index, path) => setForm((prev) => ({ ...prev, folders: prev.folders.map((row, at) => (at === index ? { ...row, path } : row)) }));
|
|
529
|
+
const save = async () => {
|
|
530
|
+
const built = formToRules(data.rules, form);
|
|
531
|
+
if (built.error) {
|
|
532
|
+
setError(t(built.error.key, built.error.params));
|
|
533
|
+
setErrors([]);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
setSaving(true);
|
|
537
|
+
setError(undefined);
|
|
538
|
+
setErrors([]);
|
|
539
|
+
try {
|
|
540
|
+
await onSave(built.rules);
|
|
541
|
+
} catch (failure) {
|
|
542
|
+
setError(t("setup.saveFailed", { message: failure instanceof Error ? failure.message : String(failure) }));
|
|
543
|
+
setErrors(failure instanceof ApiError ? failure.errors : []);
|
|
544
|
+
} finally {
|
|
545
|
+
setSaving(false);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
return h("section", { className: "c4d-section", "aria-label": t("setup.title"), "data-colco4dsh-setup": true },
|
|
549
|
+
h("h2", null, t("setup.title")),
|
|
550
|
+
notice ? h("p", { role: "status", className: "c4d-ok" }, notice) : null,
|
|
551
|
+
!data.exists ? h("p", null, t("setup.missing")) : null,
|
|
552
|
+
unreadable ? h("p", { className: "c4d-error" }, t("setup.unreadable", { path: data.path ?? "rules.json" })) : null,
|
|
553
|
+
data.exists && !data.valid && data.errors.length ? h(Fragment, null, h("p", null, t("setup.invalid")), h("ul", { className: "c4d-error" }, data.errors.map((line, index) => h("li", { key: index }, line)))) : null,
|
|
554
|
+
h(ErrorLine, { message: optionsError ? t("setup.optionsFailed", { message: optionsError }) : undefined }),
|
|
555
|
+
h("h3", null, t("setup.labels")),
|
|
556
|
+
h("p", { className: "c4d-muted" }, t("setup.labelsHint")),
|
|
557
|
+
h("ul", null, form.labels.map((row, index) => {
|
|
558
|
+
const labelChoices = row.label && !serverLabels.includes(row.label) ? [...serverLabels, row.label] : serverLabels;
|
|
559
|
+
const workflowChoices = row.workflow && !workflows.includes(row.workflow) ? [...workflows, row.workflow] : workflows;
|
|
560
|
+
return h("li", { key: index, className: "c4d-row", "data-colco4dsh-label-row": index },
|
|
561
|
+
h("select", { "aria-label": t("setup.label"), value: row.label, onChange: (event) => setLabel(index, { label: event.target.value }) },
|
|
562
|
+
h("option", { value: "" }, t("setup.chooseLabel")),
|
|
563
|
+
labelChoices.map((name) => h("option", { key: name, value: name }, serverLabels.includes(name) ? name : t("setup.notOnServer", { label: name })))),
|
|
564
|
+
h("span", { "aria-hidden": true }, "→"),
|
|
565
|
+
h("select", { "aria-label": t("setup.workflow"), value: row.workflow, onChange: (event) => setLabel(index, { workflow: event.target.value }) },
|
|
566
|
+
h("option", { value: "" }, t("setup.chooseWorkflow")),
|
|
567
|
+
workflowChoices.map((name) => h("option", { key: name, value: name }, name))),
|
|
568
|
+
h("button", { type: "button", onClick: () => setForm((prev) => ({ ...prev, labels: prev.labels.filter((_, at) => at !== index) })) }, t("setup.remove")));
|
|
569
|
+
})),
|
|
570
|
+
h("div", { className: "c4d-row" }, h("button", { type: "button", onClick: () => setForm((prev) => ({ ...prev, labels: [...prev.labels, { label: "", workflow: "" }] })) }, t("setup.addLabel"))),
|
|
571
|
+
h("h3", null, t("setup.folders")),
|
|
572
|
+
h("p", { className: "c4d-muted" }, t("setup.foldersHint")),
|
|
573
|
+
h("ul", null, form.folders.map((row, index) => h(FolderRow, {
|
|
574
|
+
key: row.index ?? `new-${index}`, t, row, check: row.index !== undefined ? checks.get(row.index) : undefined,
|
|
575
|
+
repositories: options.repositories, repo: repos[index] ?? "",
|
|
576
|
+
onRepo: (url) => setRepos((prev) => ({ ...prev, [index]: url })),
|
|
577
|
+
onPath: (path) => setFolder(index, path),
|
|
578
|
+
onRemove: () => setForm((prev) => ({ ...prev, folders: prev.folders.filter((_, at) => at !== index) })),
|
|
579
|
+
}))),
|
|
580
|
+
h("div", { className: "c4d-row" }, h("button", { type: "button", onClick: () => setForm((prev) => ({ ...prev, folders: [...prev.folders, { path: "" }] })) }, t("setup.addFolder"))),
|
|
581
|
+
h(ErrorLine, { message: error }),
|
|
582
|
+
errors.length ? h("ul", { className: "c4d-error" }, errors.map((line, index) => h("li", { key: index }, line))) : null,
|
|
583
|
+
h("div", { className: "c4d-row" },
|
|
584
|
+
h("button", { type: "button", className: "c4d-primary", disabled: saving || unreadable, "aria-busy": saving, onClick: save }, saving ? t("setup.saving") : t("setup.save")),
|
|
585
|
+
canClose ? h("button", { type: "button", onClick: onClose }, t("setup.cancel")) : null));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Loads `GET rules` and `GET options` when Setup opens, and again after a save; then shows SetupForm.
|
|
590
|
+
* `initial` skips the first load (tests render the form without a network).
|
|
591
|
+
* @param {{ t: Function, initial?: { data: object, options: object }, canClose: boolean, onSaved: () => void, onClose: () => void }} props
|
|
592
|
+
*/
|
|
593
|
+
function SetupPanel({ t, initial, canClose, onSaved, onClose }) {
|
|
594
|
+
const [loaded, setLoaded] = useState(initial ? { ...initial, optionsError: undefined } : undefined);
|
|
595
|
+
const [loadError, setLoadError] = useState(undefined);
|
|
596
|
+
const [notice, setNotice] = useState(undefined);
|
|
597
|
+
const [revision, setRevision] = useState(initial ? -1 : 0);
|
|
598
|
+
useEffect(() => {
|
|
599
|
+
if (revision < 0) return undefined;
|
|
600
|
+
const controller = new AbortController();
|
|
601
|
+
Promise.allSettled([call("rules", { signal: controller.signal }), call("options", { signal: controller.signal })]).then(([rules, options]) => {
|
|
602
|
+
if (controller.signal.aborted) return;
|
|
603
|
+
if (rules.status === "rejected") {
|
|
604
|
+
setLoadError(rules.reason instanceof Error ? rules.reason.message : String(rules.reason));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
setLoadError(undefined);
|
|
608
|
+
const opts = options.status === "fulfilled" ? options.value : {};
|
|
609
|
+
setLoaded({
|
|
610
|
+
data: { path: rules.value.path, exists: rules.value.exists === true, valid: rules.value.valid === true, errors: rules.value.errors ?? [], rules: rules.value.rules ?? null, folders: rules.value.folders ?? [] },
|
|
611
|
+
options: { labels: opts.labels ?? [], repositories: opts.repositories ?? [], workflows: opts.workflows ?? [] },
|
|
612
|
+
optionsError: options.status === "rejected" ? (options.reason instanceof Error ? options.reason.message : String(options.reason)) : undefined,
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
return () => controller.abort();
|
|
616
|
+
}, [revision]);
|
|
617
|
+
const save = useCallback(async (rules) => {
|
|
618
|
+
const reply = await call("rules", { method: "POST", body: rules });
|
|
619
|
+
setNotice(reply.backup ? t("setup.savedBackup", { path: reply.path, backup: reply.backup }) : t("setup.saved", { path: reply.path ?? "rules.json" }));
|
|
620
|
+
setRevision((value) => Math.max(value, 0) + 1);
|
|
621
|
+
onSaved();
|
|
622
|
+
}, [t, onSaved]);
|
|
623
|
+
if (loadError) return h("section", { className: "c4d-section", "aria-label": t("setup.title") }, h("h2", null, t("setup.title")), h(ErrorLine, { message: loadError }));
|
|
624
|
+
if (!loaded) return h("section", { className: "c4d-section", "aria-label": t("setup.title") }, h("h2", null, t("setup.title")), h("p", { className: "c4d-muted" }, t("setup.loading")));
|
|
625
|
+
return h(SetupForm, { key: revision, t, data: loaded.data, options: loaded.options, optionsError: loaded.optionsError, notice, canClose, onSave: save, onClose });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The whole tab: runner header, active runs, open decisions and Setup. Refreshes every POLL_MS while `visible`.
|
|
630
|
+
* `initialStatus` and `initialSetup` seed the first render (server rendering in tests performs no request).
|
|
631
|
+
* @param {{ t: Function, visible: boolean, initialStatus?: object, initialSetup?: { data: object, options: object } }} props
|
|
632
|
+
*/
|
|
633
|
+
function ColcoPanel({ t, visible, initialStatus, initialSetup }) {
|
|
634
|
+
const [status, setStatus] = useState(initialStatus);
|
|
635
|
+
const [loadError, setLoadError] = useState(undefined);
|
|
636
|
+
const [updatedAt, setUpdatedAt] = useState(undefined);
|
|
637
|
+
const [busy, setBusy] = useState(undefined);
|
|
638
|
+
const [actionError, setActionError] = useState(undefined);
|
|
639
|
+
const [setupRequested, setSetupRequested] = useState(false);
|
|
640
|
+
const inFlight = useRef(undefined);
|
|
641
|
+
const refresh = useCallback(async () => {
|
|
642
|
+
if (inFlight.current) return inFlight.current.promise;
|
|
643
|
+
const controller = new AbortController();
|
|
644
|
+
const promise = call("status", { signal: controller.signal }).then((reply) => {
|
|
645
|
+
setStatus(reply);
|
|
646
|
+
setLoadError(undefined);
|
|
647
|
+
setUpdatedAt(Date.now());
|
|
648
|
+
}, (failure) => {
|
|
649
|
+
if (!controller.signal.aborted) setLoadError(failure instanceof Error ? failure.message : String(failure));
|
|
650
|
+
}).finally(() => {
|
|
651
|
+
if (inFlight.current?.controller === controller) inFlight.current = undefined;
|
|
652
|
+
});
|
|
653
|
+
inFlight.current = { controller, promise };
|
|
654
|
+
return promise;
|
|
655
|
+
}, []);
|
|
656
|
+
useEffect(() => () => inFlight.current?.controller.abort(), []);
|
|
657
|
+
useEffect(() => {
|
|
658
|
+
if (!visible) return undefined;
|
|
659
|
+
const tick = () => { if (document.visibilityState !== "hidden") void refresh(); };
|
|
660
|
+
tick();
|
|
661
|
+
const timer = setInterval(tick, POLL_MS);
|
|
662
|
+
document.addEventListener("visibilitychange", tick);
|
|
663
|
+
return () => {
|
|
664
|
+
clearInterval(timer);
|
|
665
|
+
document.removeEventListener("visibilitychange", tick);
|
|
666
|
+
};
|
|
667
|
+
}, [visible, refresh]);
|
|
668
|
+
/** Run Start or Stop, then refresh; a failure shows under the buttons. */
|
|
669
|
+
const act = async (name) => {
|
|
670
|
+
setBusy(name);
|
|
671
|
+
setActionError(undefined);
|
|
672
|
+
try {
|
|
673
|
+
await call(name, { method: "POST" });
|
|
674
|
+
} catch (failure) {
|
|
675
|
+
setActionError(failure instanceof Error ? failure.message : String(failure));
|
|
676
|
+
} finally {
|
|
677
|
+
setBusy(undefined);
|
|
678
|
+
}
|
|
679
|
+
await refresh();
|
|
680
|
+
};
|
|
681
|
+
/** Answer a decision, then refresh; the card shows a failure itself. */
|
|
682
|
+
const answer = async (id, index, text) => {
|
|
683
|
+
await call("answer", { method: "POST", body: text === undefined ? { id, index } : { id, index, text } });
|
|
684
|
+
await refresh();
|
|
685
|
+
};
|
|
686
|
+
const runner = status?.runner ?? null;
|
|
687
|
+
const needsSetup = runner !== null && (runner.display === "not_configured" || runner.display === "rules_error");
|
|
688
|
+
const setupOpen = needsSetup || setupRequested;
|
|
689
|
+
const errors = status?.errors ?? {};
|
|
690
|
+
const now = Date.now();
|
|
691
|
+
return h("div", { className: "c4d", "data-colco4dsh-panel": runner?.display ?? "loading" },
|
|
692
|
+
h("style", null, CSS),
|
|
693
|
+
h(RunnerHeader, { t, runner, loading: status === undefined && !loadError, busy, actionError, setupOpen, onAction: act, onRefresh: () => void refresh(), onEditRules: () => setSetupRequested(true), updatedAt }),
|
|
694
|
+
h(ErrorLine, { message: loadError }),
|
|
695
|
+
["runner", "runs", "decisions"].map((section) => (errors[section] ? h(ErrorLine, { key: section, message: t("error.section", { section: t(`section.${section}`), message: errors[section] }) }) : null)),
|
|
696
|
+
setupOpen ? h(SetupPanel, { t, initial: initialSetup, canClose: !needsSetup, onSaved: () => void refresh(), onClose: () => setSetupRequested(false) }) : null,
|
|
697
|
+
h("section", { className: "c4d-section", "aria-label": t("runs.title") },
|
|
698
|
+
h("h2", null, t("runs.title")),
|
|
699
|
+
status && status.runs.length ? h("ul", null, status.runs.map((run) => h(RunCard, { key: run.id, t, run, now }))) : h("p", { className: "c4d-muted" }, status ? t("runs.empty") : t("state.loading"))),
|
|
700
|
+
h("section", { className: "c4d-section", "aria-label": t("decisions.title") },
|
|
701
|
+
h("h2", null, t("decisions.title")),
|
|
702
|
+
status && status.decisions.length ? h("ul", null, status.decisions.map((decision) => h(DecisionCard, { key: decision.id, t, decision, onAnswer: answer }))) : h("p", { className: "c4d-muted" }, status ? t("decisions.empty") : t("state.loading"))));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** English copy for hosts without DSH's locale service (tests, other hosts). */
|
|
706
|
+
const fallbackT = translator(en);
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Glyph of the left-sidebar row. Decorative: the sidebar owns the button, its label and the selected state.
|
|
710
|
+
* @param {{ size: number, active?: boolean }} props - the edge the sidebar asks for.
|
|
711
|
+
*/
|
|
712
|
+
function ColcoPanelIcon({ size }) {
|
|
713
|
+
return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", "aria-hidden": "true", focusable: "false" },
|
|
714
|
+
h("path", { d: "M16.6 7.4a6.5 6.5 0 1 0 0 9.2" }),
|
|
715
|
+
h("circle", { cx: 18.4, cy: 12, r: 1.7, fill: "currentColor", stroke: "none" }));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The Colco page in DSH's main column. DSH mounts a main panel only while it is the open one, so the panel
|
|
720
|
+
* polls for as long as this page is mounted. `t` is this namespace's translate, passed by the slot.
|
|
721
|
+
* @param {{ t?: Function, initialStatus?: object, initialSetup?: { data: object, options: object } }} props
|
|
722
|
+
*/
|
|
723
|
+
function ColcoPage({ t, initialStatus, initialSetup }) {
|
|
724
|
+
const tr = t ?? fallbackT;
|
|
725
|
+
return h("div", { className: "c4d-page" },
|
|
726
|
+
h("div", { className: "c4d-page-scroll" },
|
|
727
|
+
h("div", { className: "c4d-page-content" },
|
|
728
|
+
h("header", { className: "c4d-page-heading" },
|
|
729
|
+
h("h1", null, tr("panel.title")),
|
|
730
|
+
h("p", { className: "c4d-muted" }, tr("panel.description"))),
|
|
731
|
+
h(ColcoPanel, { t: tr, visible: true, initialStatus, initialSetup }))));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Register the copy, the Colco page in the main column and its row in the left sidebar. Plugins (order 0) and
|
|
736
|
+
* Automation tasks (order 10) come first and More always follows the rows, so order 20 sits directly above More.
|
|
737
|
+
* Every registration is removed when the plugin unloads.
|
|
738
|
+
* @param {{ effect: Function, locale: { register: Function, bind: Function }, slots: { inject: Function, register: Function } }} ctx
|
|
739
|
+
*/
|
|
740
|
+
function apply(ctx) {
|
|
741
|
+
ctx.effect(() => ctx.locale.register(NS, { en, zh }), "colco4dsh: dictionaries");
|
|
742
|
+
const t = ctx.locale.bind(NS);
|
|
743
|
+
ctx.effect(() => ctx.slots.inject("main", () => ctx.slots.register(
|
|
744
|
+
{ name: "main", key: PANEL_ID, locale: NS },
|
|
745
|
+
ColcoPage,
|
|
746
|
+
)), "colco4dsh: main panel");
|
|
747
|
+
ctx.effect(() => ctx.slots.inject("sidebar.panellist", () => ctx.slots.register(
|
|
748
|
+
{ name: "sidebar.panellist", id: PANEL_ID, order: PANEL_ORDER, locale: NS, label: () => t("panel.title") },
|
|
749
|
+
ColcoPanelIcon,
|
|
750
|
+
)), "colco4dsh: sidebar row");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return { inject: ["slots", "locale"], apply, ColcoPage, ColcoPanel, ColcoPanelIcon, SetupForm, translator, call, ApiError, dictionaries: { en, zh }, shellQuote, fixCommand, rulesToForm, formToRules, formatDuration, API, ACTION_HEADER, PANEL_ID, NS };
|
|
754
|
+
},
|
|
755
|
+
});
|