@jixoai/gh-runner 0.0.0 → 0.2.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/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/main.mjs +1656 -0
- package/dist/ui/demo.js +194 -0
- package/dist/ui/index.html +588 -0
- package/package.json +54 -6
- package/index.js +0 -1
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
原始需求(2026-10-24):配置必要表单后即可把 runner 当实例启动/关闭;
|
|
4
|
+
按运行日志实时分析最近动作成败;界面实时响应。
|
|
5
|
+
正交意图:
|
|
6
|
+
1. 快照渲染:实例卡(状态/当前作业/最近结果/启停按钮)+ 实时日志面板(选中实例)。
|
|
7
|
+
2. 命令上行:hello/ack/start/stop/register/set-auto-start/open-diag → navigator.opentray.ipc。
|
|
8
|
+
3. 演示模式:无宿主桥(普通浏览器打开)时以模拟数据驱动同一套渲染与交互,供走查与截图。
|
|
9
|
+
-->
|
|
10
|
+
<html lang="zh-CN">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8">
|
|
13
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
14
|
+
<title>Runner Fleet</title>
|
|
15
|
+
<style>
|
|
16
|
+
:root {
|
|
17
|
+
color-scheme: light dark;
|
|
18
|
+
--bg: #f5f5f7; --pane: #ffffff; --line: rgba(0,0,0,.1); --ink: #1d1d1f; --ink-2: #6e6e73;
|
|
19
|
+
--ok: #1b7f3b; --bad: #c41e25; --run: #0a5ec4; --warn: #b25e00; --idle: #8e8e93;
|
|
20
|
+
--chip: rgba(0,0,0,.05); --mono: ui-monospace, "SF Mono", Menlo, monospace;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root {
|
|
24
|
+
--bg: #1e1e20; --pane: #28282a; --line: rgba(255,255,255,.12); --ink: #f5f5f7; --ink-2: #98989d;
|
|
25
|
+
--ok: #4cd964; --bad: #ff6961; --run: #64a8ff; --warn: #ffb340; --idle: #8e8e93;
|
|
26
|
+
--chip: rgba(255,255,255,.08);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
* { box-sizing: border-box; margin: 0; }
|
|
30
|
+
html, body { height: 100%; }
|
|
31
|
+
body {
|
|
32
|
+
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
33
|
+
background: var(--bg); color: var(--ink); display: flex; flex-direction: column;
|
|
34
|
+
overflow: hidden; user-select: none;
|
|
35
|
+
}
|
|
36
|
+
header {
|
|
37
|
+
display: flex; align-items: center; gap: 12px; padding: 10px 14px;
|
|
38
|
+
border-bottom: 1px solid var(--line); flex: none;
|
|
39
|
+
}
|
|
40
|
+
header h1 { font-size: 15px; font-weight: 700; letter-spacing: .2px; }
|
|
41
|
+
#summary { color: var(--ink-2); flex: 1; }
|
|
42
|
+
button {
|
|
43
|
+
font: inherit; border: 1px solid var(--line); background: var(--pane); color: var(--ink);
|
|
44
|
+
border-radius: 7px; padding: 4px 12px; cursor: pointer; transition: opacity .15s, transform .1s;
|
|
45
|
+
}
|
|
46
|
+
button:hover { filter: brightness(1.06); }
|
|
47
|
+
button:active { transform: scale(.97); }
|
|
48
|
+
button[disabled] { opacity: .45; cursor: default; pointer-events: none; }
|
|
49
|
+
button.primary { background: var(--run); border-color: transparent; color: #fff; font-weight: 600; }
|
|
50
|
+
button.danger { color: var(--bad); }
|
|
51
|
+
main { flex: 1; display: grid; grid-template-columns: minmax(380px, 11fr) minmax(320px, 9fr); min-height: 0; }
|
|
52
|
+
.cards { overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
|
53
|
+
.card {
|
|
54
|
+
background: var(--pane); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px;
|
|
55
|
+
display: grid; grid-template-columns: 1fr auto; gap: 8px 10px; cursor: pointer;
|
|
56
|
+
transition: border-color .2s, box-shadow .2s;
|
|
57
|
+
}
|
|
58
|
+
.card.selected { border-color: var(--run); box-shadow: 0 0 0 3px color-mix(in srgb, var(--run) 22%, transparent); }
|
|
59
|
+
.card .name-row { display: flex; align-items: center; gap: 8px; }
|
|
60
|
+
.card .name { font-weight: 700; font-size: 14px; }
|
|
61
|
+
.card .meta { color: var(--ink-2); font-size: 11px; grid-column: 1 / -1; }
|
|
62
|
+
.pill {
|
|
63
|
+
font-size: 11px; padding: 1px 8px; border-radius: 99px; background: var(--chip); color: var(--ink-2);
|
|
64
|
+
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
|
|
65
|
+
}
|
|
66
|
+
.pill.busy { color: var(--run); background: color-mix(in srgb, var(--run) 14%, transparent); }
|
|
67
|
+
.pill.running { color: #23703f; background: color-mix(in srgb, var(--ok) 13%, transparent); }
|
|
68
|
+
.pill.stopped { color: var(--ink-2); }
|
|
69
|
+
.pill.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); }
|
|
70
|
+
.pill.external { color: #6b48b8; background: color-mix(in srgb, #8a63d2 16%, transparent); }
|
|
71
|
+
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--idle); flex: none; }
|
|
72
|
+
.dot.running { background: var(--ok); }
|
|
73
|
+
.dot.busy { background: var(--run); animation: pulse 1.4s ease-in-out infinite; }
|
|
74
|
+
.dot.stopping, .dot.starting { background: var(--warn); }
|
|
75
|
+
.dot.stopped { background: var(--idle); }
|
|
76
|
+
/* 核心恒不透明,仅外环呼吸——避免低相位状态"消失" */
|
|
77
|
+
@keyframes pulse {
|
|
78
|
+
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--run) 45%, transparent); }
|
|
79
|
+
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--run) 0%, transparent); }
|
|
80
|
+
}
|
|
81
|
+
.jobline { font-size: 12px; color: var(--ink-2); grid-column: 1 / -1; min-height: 18px; }
|
|
82
|
+
.jobline b { color: var(--ink); }
|
|
83
|
+
.jobline .ok { color: var(--ok); } .jobline .bad { color: var(--bad); }
|
|
84
|
+
.actions { display: flex; gap: 6px; align-items: center; }
|
|
85
|
+
.actions .icon-btn { padding: 4px 8px; font-size: 12px; }
|
|
86
|
+
.switch { display: inline-flex; align-items: center; gap: 4px; color: var(--ink-2); font-size: 11px; }
|
|
87
|
+
.logpane { border-left: 1px solid var(--line); display: flex; flex-direction: column; min-width: 0; background: var(--pane); }
|
|
88
|
+
.logpane-head {
|
|
89
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--line);
|
|
90
|
+
color: var(--ink-2); font-size: 12px; flex: none;
|
|
91
|
+
}
|
|
92
|
+
.logpane-head .log-title { font-weight: 600; color: var(--ink); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
93
|
+
#log {
|
|
94
|
+
flex: 1; overflow: auto; padding: 10px 12px; font-family: var(--mono); font-size: 11px;
|
|
95
|
+
line-height: 1.5; white-space: pre; user-select: text;
|
|
96
|
+
}
|
|
97
|
+
#log .l-ok { color: var(--ok); } #log .l-bad { color: var(--bad); } #log .l-run { color: var(--run); }
|
|
98
|
+
#log .l-dim { color: var(--ink-2); }
|
|
99
|
+
.empty { color: var(--ink-2); text-align: center; padding: 40px 0; }
|
|
100
|
+
dialog {
|
|
101
|
+
border: 1px solid var(--line); border-radius: 14px; background: var(--pane); color: var(--ink);
|
|
102
|
+
padding: 0; width: 460px; max-width: 92vw;
|
|
103
|
+
/* `* {margin:0}` 会吞掉 <dialog> 的 UA 居中 margin:auto,必须显式恢复;
|
|
104
|
+
display 只能在 [open] 态给 flex,否则盖掉 UA 的 display:none */
|
|
105
|
+
margin: auto; max-height: calc(100vh - 20px);
|
|
106
|
+
}
|
|
107
|
+
dialog[open] { display: flex; flex-direction: column; }
|
|
108
|
+
dialog::backdrop { background: rgba(0,0,0,.35); backdrop-filter: blur(2px); }
|
|
109
|
+
/* form 是 dialog 的唯一子元素,flex 链路必须经它传到 body/footer */
|
|
110
|
+
#register-form { display: flex; flex-direction: column; min-height: 0; flex: 1; }
|
|
111
|
+
#register-form .dlg-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
112
|
+
#register-form .dlg-foot { flex: 0 0 auto; }
|
|
113
|
+
.dlg-body { padding: 16px 18px 14px; overflow-y: auto; flex: 1; min-height: 0; }
|
|
114
|
+
.dlg-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px 0; }
|
|
115
|
+
.dlg-head h2 { font-size: 15px; }
|
|
116
|
+
.dlg-head .dlg-close {
|
|
117
|
+
border: none; background: none; color: var(--ink-2); font-size: 16px; padding: 2px 8px; cursor: pointer;
|
|
118
|
+
}
|
|
119
|
+
.dlg-body h2 { font-size: 15px; margin-bottom: 12px; }
|
|
120
|
+
.field { margin-bottom: 10px; display: grid; gap: 3px; }
|
|
121
|
+
.field label { font-size: 11px; color: var(--ink-2); }
|
|
122
|
+
.field input {
|
|
123
|
+
font: inherit; padding: 6px 9px; border: 1px solid var(--line); border-radius: 7px;
|
|
124
|
+
background: var(--bg); color: var(--ink); width: 100%;
|
|
125
|
+
}
|
|
126
|
+
.field input:focus { outline: 2px solid color-mix(in srgb, var(--run) 55%, transparent); border-color: transparent; }
|
|
127
|
+
.field .hint { font-size: 10.5px; color: var(--ink-2); }
|
|
128
|
+
.field .hint a { color: var(--run); text-decoration: none; }
|
|
129
|
+
.pkg-row { display: flex; gap: 6px; }
|
|
130
|
+
.pkg-row select {
|
|
131
|
+
flex: 1; min-width: 0; font: inherit; padding: 5px 7px; border: 1px solid var(--line);
|
|
132
|
+
border-radius: 7px; background: var(--bg); color: var(--ink);
|
|
133
|
+
}
|
|
134
|
+
.pkg-row button { white-space: nowrap; }
|
|
135
|
+
.pkg-row button[disabled] { opacity: .45; }
|
|
136
|
+
#pkg-progress {
|
|
137
|
+
height: 4px; border-radius: 99px; background: var(--chip); overflow: hidden; margin-top: 6px;
|
|
138
|
+
}
|
|
139
|
+
#pkg-progress-bar {
|
|
140
|
+
height: 100%; width: 0%; background: var(--run); border-radius: 99px; transition: width .3s;
|
|
141
|
+
}
|
|
142
|
+
#pkg-hint.ok { color: var(--ok); }
|
|
143
|
+
#pkg-hint.bad { color: var(--bad); }
|
|
144
|
+
.dlg-foot { display: flex; gap: 8px; justify-content: flex-end; padding: 12px 18px; border-top: 1px solid var(--line); }
|
|
145
|
+
#reg-progress {
|
|
146
|
+
font-family: var(--mono); font-size: 10.5px; max-height: 130px; overflow-y: auto; white-space: pre-wrap;
|
|
147
|
+
background: var(--bg); border-radius: 7px; padding: 8px; margin-top: 8px; display: none; user-select: text;
|
|
148
|
+
}
|
|
149
|
+
#toast {
|
|
150
|
+
position: fixed; top: 11px; right: 176px; transform: translateY(-8px);
|
|
151
|
+
background: var(--ink); color: var(--bg); border-radius: 9px; padding: 6px 14px; font-size: 12px;
|
|
152
|
+
opacity: 0; pointer-events: none; transition: all .25s; max-width: 46%; z-index: 50;
|
|
153
|
+
}
|
|
154
|
+
#toast.show { opacity: 1; transform: translateY(0); }
|
|
155
|
+
.scroll-thin { scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--ink-2) 45%, transparent) transparent; }
|
|
156
|
+
.cards::-webkit-scrollbar, #log::-webkit-scrollbar, #reg-progress::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
157
|
+
.cards::-webkit-scrollbar-thumb, #log::-webkit-scrollbar-thumb, #reg-progress::-webkit-scrollbar-thumb {
|
|
158
|
+
background: color-mix(in srgb, var(--ink-2) 45%, transparent); border-radius: 99px;
|
|
159
|
+
}
|
|
160
|
+
.cards::-webkit-scrollbar-track, #log::-webkit-scrollbar-track { background: transparent; }
|
|
161
|
+
</style>
|
|
162
|
+
</head>
|
|
163
|
+
<body>
|
|
164
|
+
<header>
|
|
165
|
+
<h1>Runner Fleet</h1>
|
|
166
|
+
<div id="summary">连接中…</div>
|
|
167
|
+
<button class="primary" id="btn-new">+ 注册新实例</button>
|
|
168
|
+
</header>
|
|
169
|
+
<main>
|
|
170
|
+
<section id="cards" class="cards scroll-thin"></section>
|
|
171
|
+
<section class="logpane">
|
|
172
|
+
<div class="logpane-head">
|
|
173
|
+
<span class="log-title" id="log-title">日志(选择实例)</span>
|
|
174
|
+
<label class="switch"><input type="checkbox" id="autoscroll" checked> 自动滚动</label>
|
|
175
|
+
<button class="icon-btn" id="btn-diag" title="在 Finder 中打开该实例的 _diag 目录">⌖ 日志目录</button>
|
|
176
|
+
</div>
|
|
177
|
+
<pre id="log" class="scroll-thin"></pre>
|
|
178
|
+
</section>
|
|
179
|
+
</main>
|
|
180
|
+
|
|
181
|
+
<dialog id="register-dlg">
|
|
182
|
+
<form id="register-form" method="dialog">
|
|
183
|
+
<div class="dlg-head">
|
|
184
|
+
<h2>注册新 runner 实例</h2>
|
|
185
|
+
<button type="button" class="dlg-close" id="dlg-close" title="关闭">✕</button>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="dlg-body">
|
|
188
|
+
<div class="field">
|
|
189
|
+
<label>实例目录(必须为空目录)</label>
|
|
190
|
+
<input name="dir" placeholder="/Volumes/bnqkl/Users/kzf/actions-runner-5" required>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="field">
|
|
193
|
+
<label>实例名称</label>
|
|
194
|
+
<input name="name" placeholder="mac-ext-05" required>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="field">
|
|
197
|
+
<label>注册 Token(org 设置页生成,一次性)</label>
|
|
198
|
+
<input name="token" type="password" placeholder="粘贴一次性注册 Token" required>
|
|
199
|
+
<span class="hint">获取:<a href="https://github.com/organizations/jixoai/settings/actions/runners/new" target="_blank">org runners 新建页</a>(自动解包本机已有分发包)</span>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="field">
|
|
202
|
+
<label>Labels</label>
|
|
203
|
+
<input name="labels" value="self-hosted,macOS,ARM64">
|
|
204
|
+
</div>
|
|
205
|
+
<div class="field">
|
|
206
|
+
<label>GitHub org URL</label>
|
|
207
|
+
<input name="orgUrl" value="https://github.com/jixoai">
|
|
208
|
+
</div>
|
|
209
|
+
<div class="field">
|
|
210
|
+
<label>Work 目录(必填,独立于其它实例,避免缓存互踩)</label>
|
|
211
|
+
<input name="workFolder" placeholder="/Volumes/bnqkl/Users/kzf/gh-runner-work-5" required>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="field">
|
|
214
|
+
<label>分发包(解包并注册;可从 GitHub 下载最新或导入本地包)</label>
|
|
215
|
+
<div class="pkg-row">
|
|
216
|
+
<select id="pkg-select" name="packagePath" class="scroll-thin"></select>
|
|
217
|
+
<button type="button" id="pkg-download" title="从 actions/runner 官方 release 下载当前平台最新包">⤓ 最新</button>
|
|
218
|
+
<button type="button" id="pkg-import" title="选择本地包文件(tar.gz / zip)">导入…</button>
|
|
219
|
+
</div>
|
|
220
|
+
<span class="hint" id="pkg-hint"></span>
|
|
221
|
+
<div id="pkg-progress" style="display:none"><div id="pkg-progress-bar"></div></div>
|
|
222
|
+
</div>
|
|
223
|
+
<pre id="reg-progress" class="scroll-thin"></pre>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="dlg-foot">
|
|
226
|
+
<button type="button" id="reg-cancel">取消</button>
|
|
227
|
+
<button type="submit" class="primary" id="reg-submit">注册并启动</button>
|
|
228
|
+
</div>
|
|
229
|
+
</form>
|
|
230
|
+
</dialog>
|
|
231
|
+
<div id="toast"></div>
|
|
232
|
+
|
|
233
|
+
<script>
|
|
234
|
+
"use strict";
|
|
235
|
+
const $ = (sel) => document.querySelector(sel);
|
|
236
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
237
|
+
const state = { snap: null, selected: null, logs: new Map(), boot: Date.now(), platform: null, labelsTouched: false };
|
|
238
|
+
|
|
239
|
+
/* ---------- 上行通道 ---------- */
|
|
240
|
+
function send(cmd) {
|
|
241
|
+
const ipc = navigator.opentray && navigator.opentray.ipc;
|
|
242
|
+
if (ipc) void ipc.postMessage(cmd);
|
|
243
|
+
else demoCommand(cmd);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* ---------- 推送接收(宿主 evaluate 注入;summary 与 log 增量双通道) ---------- */
|
|
247
|
+
window.__rfSink = (msg) => {
|
|
248
|
+
if (msg.kind === "summary") {
|
|
249
|
+
state.snap = msg;
|
|
250
|
+
if (!state.platform) applyPlatformDefaults(msg.platform, msg.arch);
|
|
251
|
+
render();
|
|
252
|
+
renderPackages(msg.packages, msg.download);
|
|
253
|
+
} else if (msg.kind === "log") {
|
|
254
|
+
let buf = state.logs.get(msg.id);
|
|
255
|
+
if (!buf) { buf = []; state.logs.set(msg.id, buf); }
|
|
256
|
+
if (msg.truncated) buf.length = 0;
|
|
257
|
+
for (const line of msg.lines) buf.push(line);
|
|
258
|
+
if (buf.length > 800) buf.splice(0, buf.length - 600);
|
|
259
|
+
if (msg.id === state.selected) appendLogLines(msg.lines, msg.truncated);
|
|
260
|
+
}
|
|
261
|
+
send({ type: "ack", seq: msg.seq });
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/* ---------- 平台默认值(首次 summary 时应用,不覆盖用户已编辑的字段) ---------- */
|
|
265
|
+
function applyPlatformDefaults(platform, arch) {
|
|
266
|
+
state.platform = platform;
|
|
267
|
+
const isWin = platform === "win32";
|
|
268
|
+
const labels = platform === "darwin"
|
|
269
|
+
? "self-hosted,macOS,ARM64"
|
|
270
|
+
: isWin
|
|
271
|
+
? `self-hosted,Windows,${(arch === "arm64" ? "ARM64" : "X64")}`
|
|
272
|
+
: `self-hosted,Linux,${(arch === "arm64" ? "ARM64" : "X64")}`;
|
|
273
|
+
const labelsInput = document.querySelector('#register-form input[name="labels"]');
|
|
274
|
+
if (labelsInput && !state.labelsTouched) labelsInput.value = labels;
|
|
275
|
+
const dirInput = document.querySelector('#register-form input[name="dir"]');
|
|
276
|
+
if (dirInput) dirInput.placeholder = isWin ? "C:\\actions-runner-5" : "/Volumes/bnqkl/Users/kzf/actions-runner-5";
|
|
277
|
+
const workInput = document.querySelector('#register-form input[name="workFolder"]');
|
|
278
|
+
if (workInput) workInput.placeholder = isWin ? "C:\\gh-runner-work-5" : "/Volumes/bnqkl/Users/kzf/gh-runner-work-5";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ---------- 包清单 / 下载进度 ---------- */
|
|
282
|
+
function fmtSize(bytes) {
|
|
283
|
+
if (bytes >= 1 << 30) return (bytes / (1 << 30)).toFixed(1) + " GB";
|
|
284
|
+
if (bytes >= 1 << 20) return (bytes / (1 << 20)).toFixed(0) + " MB";
|
|
285
|
+
return Math.max(1, Math.round(bytes / (1 << 10))) + " KB";
|
|
286
|
+
}
|
|
287
|
+
function renderPackages(packages, download) {
|
|
288
|
+
const sel = $("#pkg-select");
|
|
289
|
+
const prev = sel.value;
|
|
290
|
+
sel.innerHTML = "";
|
|
291
|
+
if (!packages || packages.length === 0) {
|
|
292
|
+
const opt = document.createElement("option");
|
|
293
|
+
opt.value = "";
|
|
294
|
+
opt.textContent = "(暂无包:点「⤓ 最新」下载或「导入…」)";
|
|
295
|
+
sel.appendChild(opt);
|
|
296
|
+
}
|
|
297
|
+
for (const pkg of packages || []) {
|
|
298
|
+
const opt = document.createElement("option");
|
|
299
|
+
opt.value = pkg.path;
|
|
300
|
+
opt.textContent = `${pkg.name}(${fmtSize(pkg.sizeBytes)})${pkg.compatible ? "" : " · 不兼容"}`;
|
|
301
|
+
opt.disabled = !pkg.compatible;
|
|
302
|
+
sel.appendChild(opt);
|
|
303
|
+
}
|
|
304
|
+
if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev;
|
|
305
|
+
|
|
306
|
+
const hint = $("#pkg-hint");
|
|
307
|
+
const bar = $("#pkg-progress");
|
|
308
|
+
const barFill = $("#pkg-progress-bar");
|
|
309
|
+
const busy = download && download.status === "running";
|
|
310
|
+
$("#pkg-download").disabled = !!busy;
|
|
311
|
+
$("#pkg-import").disabled = !!busy;
|
|
312
|
+
if (!download) {
|
|
313
|
+
hint.className = "hint";
|
|
314
|
+
hint.textContent = packages && packages.length > 0 ? "" : "";
|
|
315
|
+
bar.style.display = "none";
|
|
316
|
+
} else if (download.status === "running") {
|
|
317
|
+
hint.className = "hint";
|
|
318
|
+
hint.textContent = download.message;
|
|
319
|
+
bar.style.display = "block";
|
|
320
|
+
barFill.style.width = download.pct + "%";
|
|
321
|
+
} else if (download.status === "ok") {
|
|
322
|
+
hint.className = "hint ok";
|
|
323
|
+
hint.textContent = "✔ " + download.message;
|
|
324
|
+
bar.style.display = "none";
|
|
325
|
+
} else {
|
|
326
|
+
hint.className = "hint bad";
|
|
327
|
+
hint.textContent = "✘ " + download.message;
|
|
328
|
+
bar.style.display = "none";
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
$("#pkg-download").addEventListener("click", () => send({ type: "download-latest" }));
|
|
333
|
+
$("#pkg-import").addEventListener("click", () => send({ type: "import-package" }));
|
|
334
|
+
document.querySelector('#register-form input[name="labels"]').addEventListener("input", () => {
|
|
335
|
+
state.labelsTouched = true;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
/* ---------- 渲染 ---------- */
|
|
339
|
+
function dotClass(inst) {
|
|
340
|
+
if (inst.currentJob && inst.processState === "running") return "busy";
|
|
341
|
+
return inst.processState === "running" ? "running" : inst.processState;
|
|
342
|
+
}
|
|
343
|
+
function stateText(inst) {
|
|
344
|
+
if (inst.currentJob) return "作业中";
|
|
345
|
+
const map = { running: "空闲", stopped: "已停止", starting: "启动中…", stopping: "停止中…" };
|
|
346
|
+
return map[inst.processState] || inst.processState;
|
|
347
|
+
}
|
|
348
|
+
function fmtDuration(ms) {
|
|
349
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
350
|
+
if (s < 5) return "刚刚";
|
|
351
|
+
if (s < 60) return s + "s";
|
|
352
|
+
const m = Math.floor(s / 60);
|
|
353
|
+
if (m < 60) return s % 60 === 0 ? m + "m" : m + "m" + (s % 60) + "s";
|
|
354
|
+
const h = Math.floor(m / 60);
|
|
355
|
+
return h + "h" + (m % 60) + "m";
|
|
356
|
+
}
|
|
357
|
+
function fmtAgo(iso) {
|
|
358
|
+
return fmtDuration(Math.max(0, Date.now() - Date.parse(iso))) + " 前";
|
|
359
|
+
}
|
|
360
|
+
function outcomeBadge(run) {
|
|
361
|
+
const ok = run.outcome === "Succeeded" || run.outcome === "SucceededWithIssues";
|
|
362
|
+
const cls = ok ? "ok" : (run.outcome === "Canceled" || run.outcome === "Skipped" ? "warn" : "bad");
|
|
363
|
+
const mark = ok ? "✔" : "✘";
|
|
364
|
+
return `<b>${esc(run.job)}</b> → <span class="${cls}">${mark} ${esc(run.outcome)}</span>` +
|
|
365
|
+
`<span style="color:var(--ink-2)">(${fmtDuration(run.durationMs)},${fmtAgo(run.at)})</span>`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function render() {
|
|
369
|
+
const snap = state.snap;
|
|
370
|
+
if (!snap) return;
|
|
371
|
+
const prevSelected = state.renderedSelected;
|
|
372
|
+
const running = snap.instances.filter((i) => i.processState === "running" || i.processState === "starting").length;
|
|
373
|
+
const busy = snap.instances.filter((i) => i.currentJob).length;
|
|
374
|
+
$("#summary").textContent = `${snap.instances.length} 实例 · ${running} 在跑 · ${busy} 作业中` +
|
|
375
|
+
(snap.task.kind === "register" && snap.task.status === "running" ? " · 注册进行中" : "");
|
|
376
|
+
if (snap.task.kind === "register") toastForTask(snap.task);
|
|
377
|
+
|
|
378
|
+
if (state.selected && !snap.instances.some((i) => i.id === state.selected)) state.selected = null;
|
|
379
|
+
if (!state.selected && snap.instances.length > 0) state.selected = snap.instances[0].id;
|
|
380
|
+
|
|
381
|
+
const cards = $("#cards");
|
|
382
|
+
const seen = new Set();
|
|
383
|
+
for (const inst of snap.instances) {
|
|
384
|
+
seen.add(inst.id);
|
|
385
|
+
let card = cards.querySelector(`[data-id="${CSS.escape(inst.id)}"]`);
|
|
386
|
+
const sig = JSON.stringify([inst.processState, inst.external, inst.currentJob, inst.lastResult, inst.lastError, inst.autoStart, inst.configured]);
|
|
387
|
+
if (!card) {
|
|
388
|
+
card = document.createElement("div");
|
|
389
|
+
card.className = "card";
|
|
390
|
+
card.dataset.id = inst.id;
|
|
391
|
+
card.addEventListener("click", () => selectInstance(inst.id));
|
|
392
|
+
cards.appendChild(card);
|
|
393
|
+
card.dataset.sig = "";
|
|
394
|
+
}
|
|
395
|
+
if (card.dataset.sig !== sig) {
|
|
396
|
+
card.dataset.sig = sig;
|
|
397
|
+
card.innerHTML = cardHtml(inst);
|
|
398
|
+
wireCard(card, inst);
|
|
399
|
+
}
|
|
400
|
+
card.classList.toggle("selected", inst.id === state.selected);
|
|
401
|
+
}
|
|
402
|
+
for (const el of [...cards.children]) if (!seen.has(el.dataset.id)) el.remove();
|
|
403
|
+
if (state.selected !== prevSelected) renderLogFull();
|
|
404
|
+
state.renderedSelected = state.selected;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function cardHtml(inst) {
|
|
408
|
+
const job = inst.currentJob
|
|
409
|
+
? `作业中 <b>${esc(inst.currentJob.job)}</b>(已运行 <span class="elapsed" data-start="${inst.currentJob.startedAt}">${fmtDuration(Date.now() - Date.parse(inst.currentJob.startedAt))}</span>)`
|
|
410
|
+
: inst.lastResult
|
|
411
|
+
? `最近动作 ${outcomeBadge(inst.lastResult)}`
|
|
412
|
+
: "暂无作业记录";
|
|
413
|
+
const err = inst.lastError ? `<div class="jobline" style="color:var(--bad)">⚠ ${esc(inst.lastError)}</div>` : "";
|
|
414
|
+
const startDisabled = inst.processState !== "stopped" || !inst.configured;
|
|
415
|
+
const stopDisabled = inst.processState !== "running" && inst.processState !== "starting";
|
|
416
|
+
return `
|
|
417
|
+
<div class="name-row">
|
|
418
|
+
<span class="dot ${dotClass(inst)}"></span>
|
|
419
|
+
<span class="name">${esc(inst.name)}</span>
|
|
420
|
+
<span class="pill ${dotClass(inst)}">${stateText(inst)}</span>
|
|
421
|
+
${inst.external ? '<span class="pill external" title="由其它终端/launchd 启动,非本工具拉起">外部</span>' : ""}
|
|
422
|
+
${inst.configured ? "" : '<span class="pill warn">未注册</span>'}
|
|
423
|
+
</div>
|
|
424
|
+
<div class="actions">
|
|
425
|
+
<button class="icon-btn" data-act="start" ${startDisabled ? "disabled" : ""}>▶ 启动</button>
|
|
426
|
+
<button class="icon-btn danger" data-act="stop" ${stopDisabled ? "disabled" : ""}>■ 停止</button>
|
|
427
|
+
<label class="switch" title="工具启动时自动拉起该实例">
|
|
428
|
+
<input type="checkbox" data-act="autostart" ${inst.autoStart ? "checked" : ""}>自启
|
|
429
|
+
</label>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="meta">#${inst.agentId ?? "—"} · ${esc(inst.dir)}</div>
|
|
432
|
+
<div class="jobline">${job}</div>${err}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function wireCard(card, inst) {
|
|
436
|
+
card.querySelector('[data-act="start"]').addEventListener("click", (e) => {
|
|
437
|
+
e.stopPropagation();
|
|
438
|
+
e.target.disabled = true; e.target.textContent = "启动中…";
|
|
439
|
+
send({ type: "start", id: inst.id });
|
|
440
|
+
});
|
|
441
|
+
card.querySelector('[data-act="stop"]').addEventListener("click", (e) => {
|
|
442
|
+
e.stopPropagation();
|
|
443
|
+
if (inst.currentJob && !confirm(`「${inst.name}」正在作业 ${inst.currentJob.job}。\n强制停止会把该作业标记为失败。确定停止?`)) return;
|
|
444
|
+
e.target.disabled = true; e.target.textContent = "停止中…";
|
|
445
|
+
send({ type: "stop", id: inst.id });
|
|
446
|
+
});
|
|
447
|
+
card.querySelector('[data-act="autostart"]').addEventListener("change", (e) => {
|
|
448
|
+
send({ type: "set-auto-start", id: inst.id, value: e.target.checked });
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function selectInstance(id) {
|
|
453
|
+
state.selected = id;
|
|
454
|
+
render();
|
|
455
|
+
renderLogFull();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function classify(line) {
|
|
459
|
+
if (/completed with result: (Succeeded|SucceededWithIssues|PartiallySucceeded)/.test(line)) return "l-ok";
|
|
460
|
+
if (/completed with result: (Failed|Canceled)/.test(line)) return "l-bad";
|
|
461
|
+
if (/Running job:/.test(line)) return "l-run";
|
|
462
|
+
if (/^\$|✔|✘/.test(line.trim())) return "l-dim";
|
|
463
|
+
return "";
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function appendLogLines(lines, reset) {
|
|
467
|
+
const logEl = $("#log");
|
|
468
|
+
if (reset || logEl.querySelector(".empty")) logEl.innerHTML = "";
|
|
469
|
+
const pinned = $("#autoscroll").checked && logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40;
|
|
470
|
+
const frag = document.createDocumentFragment();
|
|
471
|
+
for (const line of lines) {
|
|
472
|
+
const div = document.createElement("div");
|
|
473
|
+
if (line) {
|
|
474
|
+
const cls = classify(line);
|
|
475
|
+
// 时间戳恒为中性灰,只对事件语义着色,避免整行彩墙
|
|
476
|
+
const m = cls && cls !== "l-dim" ? /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z): (.*)$/.exec(line) : null;
|
|
477
|
+
if (m) {
|
|
478
|
+
const ts = document.createElement("span");
|
|
479
|
+
ts.className = "l-dim";
|
|
480
|
+
ts.textContent = m[1] + ": ";
|
|
481
|
+
const body = document.createElement("span");
|
|
482
|
+
body.className = cls;
|
|
483
|
+
body.textContent = m[2];
|
|
484
|
+
div.append(ts, body);
|
|
485
|
+
} else {
|
|
486
|
+
div.className = cls;
|
|
487
|
+
div.textContent = line;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
frag.appendChild(div);
|
|
491
|
+
}
|
|
492
|
+
logEl.appendChild(frag);
|
|
493
|
+
if (pinned || reset) logEl.scrollTop = logEl.scrollHeight;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function renderLogFull() {
|
|
497
|
+
const logEl = $("#log");
|
|
498
|
+
const buf = state.logs.get(state.selected);
|
|
499
|
+
const inst = state.snap?.instances.find((i) => i.id === state.selected);
|
|
500
|
+
$("#log-title").textContent = state.selected
|
|
501
|
+
? `日志 — ${inst?.name ?? state.selected}`
|
|
502
|
+
: "日志(选择实例)";
|
|
503
|
+
logEl.innerHTML = "";
|
|
504
|
+
if (!inst) { logEl.innerHTML = '<div class="empty">尚无实例</div>'; return; }
|
|
505
|
+
if (!buf || buf.length === 0) { logEl.innerHTML = '<div class="empty">暂无日志</div>'; return; }
|
|
506
|
+
appendLogLines(buf, false);
|
|
507
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* 作业已运行时长:本地 1s 心跳,只改文本节点,不整体重渲染 */
|
|
511
|
+
setInterval(() => {
|
|
512
|
+
document.querySelectorAll(".elapsed").forEach((el) => {
|
|
513
|
+
el.textContent = fmtDuration(Date.now() - Date.parse(el.dataset.start));
|
|
514
|
+
});
|
|
515
|
+
}, 1000);
|
|
516
|
+
|
|
517
|
+
/* ---------- 注册表单 ---------- */
|
|
518
|
+
$("#btn-new").addEventListener("click", () => {
|
|
519
|
+
$("#reg-progress").style.display = "none";
|
|
520
|
+
$("#reg-progress").textContent = "";
|
|
521
|
+
const submitBtn = $("#reg-submit");
|
|
522
|
+
submitBtn.disabled = false;
|
|
523
|
+
submitBtn.textContent = "注册并启动";
|
|
524
|
+
$("#register-dlg").showModal();
|
|
525
|
+
});
|
|
526
|
+
$("#reg-cancel").addEventListener("click", () => $("#register-dlg").close());
|
|
527
|
+
$("#dlg-close").addEventListener("click", () => $("#register-dlg").close());
|
|
528
|
+
$("#register-form").addEventListener("submit", (e) => {
|
|
529
|
+
e.preventDefault();
|
|
530
|
+
const fd = new FormData(e.target);
|
|
531
|
+
const get = (k) => String(fd.get(k) ?? "").trim();
|
|
532
|
+
const progress = $("#reg-progress");
|
|
533
|
+
progress.style.display = "block";
|
|
534
|
+
progress.scrollIntoView({ block: "nearest" });
|
|
535
|
+
const submitBtn = $("#reg-submit");
|
|
536
|
+
submitBtn.disabled = true;
|
|
537
|
+
submitBtn.textContent = "注册中…";
|
|
538
|
+
send({
|
|
539
|
+
type: "register",
|
|
540
|
+
dir: get("dir"), name: get("name"), token: get("token"), labels: get("labels"),
|
|
541
|
+
orgUrl: get("orgUrl"), workFolder: get("workFolder"),
|
|
542
|
+
packagePath: get("packagePath"),
|
|
543
|
+
});
|
|
544
|
+
progress.textContent += "已提交注册请求…\n";
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
let toastTimer = null;
|
|
548
|
+
function toast(msg) {
|
|
549
|
+
const el = $("#toast");
|
|
550
|
+
el.textContent = msg;
|
|
551
|
+
el.classList.add("show");
|
|
552
|
+
clearTimeout(toastTimer);
|
|
553
|
+
toastTimer = setTimeout(() => el.classList.remove("show"), 3500);
|
|
554
|
+
}
|
|
555
|
+
let lastTaskAnnounced = "";
|
|
556
|
+
function toastForTask(task) {
|
|
557
|
+
const key = task.kind + task.status + (task.message ?? "");
|
|
558
|
+
if (key === lastTaskAnnounced) return;
|
|
559
|
+
lastTaskAnnounced = key;
|
|
560
|
+
if (task.status === "ok") { toast("✔ " + (task.message ?? "完成")); $("#register-dlg").close(); }
|
|
561
|
+
else if (task.status === "error") toast("✘ " + (task.message ?? "失败"));
|
|
562
|
+
else if (task.message) {
|
|
563
|
+
const p = $("#reg-progress");
|
|
564
|
+
p.textContent += task.message + "\n";
|
|
565
|
+
p.scrollTop = p.scrollHeight;
|
|
566
|
+
if (!$("#register-dlg").open) $("#register-dlg").showModal();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
$("#btn-diag").addEventListener("click", () => {
|
|
571
|
+
if (state.selected) send({ type: "open-diag", id: state.selected });
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
/* ---------- 演示模式(无宿主桥的普通浏览器) ---------- */
|
|
575
|
+
function demoCommand(cmd) {
|
|
576
|
+
window.__demo && window.__demo.command(cmd);
|
|
577
|
+
}
|
|
578
|
+
if (!(navigator.opentray && navigator.opentray.ipc)) {
|
|
579
|
+
const script = document.createElement("script");
|
|
580
|
+
script.src = "./demo.js";
|
|
581
|
+
script.onerror = () => { $("#summary").textContent = "(未连接宿主)"; };
|
|
582
|
+
document.head.appendChild(script);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
send({ type: "hello" });
|
|
586
|
+
</script>
|
|
587
|
+
</body>
|
|
588
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,9 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jixoai/gh-runner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Menu-bar (tray) manager for multiple GitHub Actions self-hosted runners: discover, register from downloaded/imported packages, start/stop, and live job-result analysis. macOS & Windows.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/jixoai/gh-runner.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/jixoai/gh-runner/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/jixoai/gh-runner#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"github-actions",
|
|
17
|
+
"self-hosted-runner",
|
|
18
|
+
"tray",
|
|
19
|
+
"menubar",
|
|
20
|
+
"runner-fleet"
|
|
21
|
+
],
|
|
22
|
+
"bin": {
|
|
23
|
+
"gh-runner": "./dist/main.mjs"
|
|
24
|
+
},
|
|
6
25
|
"files": [
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"packageManager": "pnpm@12.4.1",
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsdown && node scripts/copy-ui.mjs",
|
|
39
|
+
"start": "node dist/main.mjs",
|
|
40
|
+
"dev": "pnpm build && node dist/main.mjs --dry-run 4",
|
|
41
|
+
"smoke": "pnpm build && node dist/main.mjs --smoke",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"prepublishOnly": "pnpm typecheck && pnpm test && pnpm build"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@opentray/ext-dialog": "^0.36.0",
|
|
48
|
+
"@opentray/ext-webview": "^0.36.0",
|
|
49
|
+
"opentray": "^0.36.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^26.6.5",
|
|
53
|
+
"tsdown": "^0.23.1",
|
|
54
|
+
"typescript": "^7.0.2",
|
|
55
|
+
"vitest": "^5.0.5"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
module.exports = {};
|