@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.10
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/README.md +14 -15
- package/dist/bin/codex-remote.mjs +81 -26
- package/dist/bin/self-relay-admin.mjs +8 -0
- package/dist/bin/self-relay-demo.mjs +1 -1
- package/dist/bin/self-relay-server.mjs +5 -2
- package/dist/shared/app-server-events.cjs +50 -22
- package/dist/shared/p2p-data-channel.cjs +50 -0
- package/dist/skills/code-relax/SKILL.md +1 -1
- package/dist/src/desktop-launcher.mjs +11 -11
- package/dist/src/lan-socket-server.mjs +8 -2
- package/dist/src/managed-app-server.mjs +27 -7
- package/dist/src/message-images.mjs +0 -18
- package/dist/src/onboarding.mjs +3 -5
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
- package/dist/src/platform/windows/background-process.mjs +7 -2
- package/dist/src/platform/windows/codex-runtime.mjs +117 -0
- package/dist/src/platform/windows/desktop-host.mjs +16 -1
- package/dist/src/platform/windows/desktop-info.ps1 +2 -4
- package/dist/src/platform/windows/desktop-shortcut-run.ps1 +1 -1
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +1 -1
- package/dist/src/platform/windows/launch-worker.mjs +42 -0
- package/dist/src/platform/windows/resident-startup.ps1 +58 -18
- package/dist/src/platform/windows/service-host.mjs +4 -7
- package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
- package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
- package/dist/src/runtime-discovery.mjs +43 -0
- package/dist/src/self-relay/admin-cloud.mjs +101 -0
- package/dist/src/self-relay/admin-page.mjs +71 -0
- package/dist/src/self-relay/admin-server.mjs +61 -0
- package/dist/src/self-relay/admin-state.mjs +122 -0
- package/dist/src/self-relay/client.mjs +2 -0
- package/dist/src/self-relay/connector.mjs +23 -8
- package/dist/src/self-relay/demo.mjs +4 -5
- package/dist/src/self-relay/lifecycle.mjs +53 -49
- package/dist/src/self-relay/p2p-probe.mjs +123 -83
- package/dist/src/self-relay/server.mjs +157 -17
- package/dist/src/server.mjs +484 -1274
- package/dist/src/service-doctor.mjs +13 -25
- package/dist/src/service-lifecycle.mjs +12 -12
- package/dist/src/shared-app-server.mjs +192 -34
- package/dist/src/shared-catalog.mjs +4 -14
- package/dist/src/shared-recovery.mjs +8 -6
- package/dist/src/shared-thread-stream.mjs +1 -1
- package/dist/web/activity-view.js +283 -0
- package/dist/web/capabilities.js +2 -9
- package/dist/web/chat-transport.js +21 -21
- package/dist/web/chat.css +148 -106
- package/dist/web/chat.js +1454 -2788
- package/dist/web/community.css +82 -0
- package/dist/web/community.html +36 -0
- package/dist/web/composer-controller.js +97 -0
- package/dist/web/conversation-controller.js +99 -0
- package/dist/web/disclosure-state-controller.js +95 -0
- package/dist/web/draft-controller.js +103 -0
- package/dist/web/harmony-platform.js +3 -2
- package/dist/web/history-cache.js +112 -18
- package/dist/web/history-controller.js +167 -0
- package/dist/web/index.html +113 -47
- package/dist/web/link-action-controller.js +212 -0
- package/dist/web/message-send-controller.js +177 -0
- package/dist/web/message-view.js +98 -0
- package/dist/web/p2p-data-channel.js +50 -0
- package/dist/web/p2p-probe.js +67 -27
- package/dist/web/page-resume.js +28 -0
- package/dist/web/pending-message-store.js +108 -0
- package/dist/web/queue-controller.js +82 -0
- package/dist/web/resources.json +1 -1
- package/dist/web/self-relay-session.js +28 -18
- package/dist/web/station-connection-controller.js +75 -0
- package/dist/web/task-list-view.js +289 -0
- package/dist/web/thread-attention-controller.js +124 -0
- package/dist/web/thread-context-controller.js +30 -0
- package/dist/web/thread-list-controller.js +61 -0
- package/dist/web/thread-list-sync.js +86 -0
- package/dist/web/thread-title-controller.js +57 -0
- package/dist/web/timeline-formatters.js +249 -0
- package/dist/web/timeline-reducer.js +77 -0
- package/dist/web/timeline-renderer.js +161 -0
- package/dist/web/timeline-scroll-controller.js +50 -0
- package/dist/web/usage-controller.js +258 -0
- package/dist/web/vendor/lucide.LICENSE.txt +17 -0
- package/package.json +16 -18
- package/tools/postinstall.mjs +140 -3
- package/dist/src/app-server-client.mjs +0 -468
- package/dist/src/app-server-tasks.mjs +0 -355
- package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
- package/dist/src/platform/windows/desktop-tools.mjs +0 -48
- package/dist/src/platform/windows/launch-worker.ps1 +0 -13
- package/dist/src/thread-catalog.mjs +0 -42
- package/dist/tools/find-desktop-pipe.mjs +0 -10
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { discoverCodex, resolveCodexRuntime } from './platform/windows/codex-runtime.mjs';
|
|
3
|
+
|
|
4
|
+
// Advisory discovery is independent of execution health. Failure here must never
|
|
5
|
+
// disable an already running app-server or cause an automatic process restart.
|
|
6
|
+
export class RuntimeDiscovery {
|
|
7
|
+
constructor({ stateRoot, host, url, interval = 60000 }) {
|
|
8
|
+
Object.assign(this, { stateRoot, host, url, interval });
|
|
9
|
+
this.state = {};
|
|
10
|
+
}
|
|
11
|
+
start() { this.stopped = false; void this.tick(); }
|
|
12
|
+
stop() { this.stopped = true; clearTimeout(this.timer); }
|
|
13
|
+
async sample() {
|
|
14
|
+
const errors = [];
|
|
15
|
+
try {
|
|
16
|
+
const candidates = discoverCodex();
|
|
17
|
+
const signature = JSON.stringify(candidates.map(candidate => {
|
|
18
|
+
const stat = fs.statSync(candidate.executable);
|
|
19
|
+
return [candidate.executable, stat.size, stat.mtimeMs];
|
|
20
|
+
}));
|
|
21
|
+
if (signature !== this.signature) {
|
|
22
|
+
await resolveCodexRuntime(this.stateRoot, { refresh: true });
|
|
23
|
+
this.signature = signature;
|
|
24
|
+
}
|
|
25
|
+
} catch (error) { errors.push(error.message); }
|
|
26
|
+
try {
|
|
27
|
+
const desktop = await this.host.inspect();
|
|
28
|
+
const attached = desktop.running && await this.host.sharedServiceUrl() === this.url;
|
|
29
|
+
this.state = { desktop: { state: !desktop.running ? 'stopped' : attached ? 'managed' : 'ordinary',
|
|
30
|
+
installed: Boolean(desktop.desktop), attachedToManagedServer: Boolean(attached) },
|
|
31
|
+
desktopRestartRecommended: Boolean(desktop.running && !attached), checkedAt: new Date().toISOString() };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
errors.push(error.message);
|
|
34
|
+
this.state = { checkedAt: new Date().toISOString() };
|
|
35
|
+
}
|
|
36
|
+
if (errors.length) this.state.discoveryError = errors.join('\n');
|
|
37
|
+
return this.state;
|
|
38
|
+
}
|
|
39
|
+
async tick() {
|
|
40
|
+
await this.sample();
|
|
41
|
+
if (!this.stopped) { this.timer = setTimeout(() => this.tick(), this.interval); this.timer.unref?.(); }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import tencentcloud from 'tencentcloud-sdk-nodejs-common';
|
|
3
|
+
|
|
4
|
+
const INSTANCE_ID = 'lhins-byrs7n8o';
|
|
5
|
+
const REGION = 'ap-beijing';
|
|
6
|
+
const { CommonClient } = tencentcloud;
|
|
7
|
+
const METRICS = [
|
|
8
|
+
['LighthouseOutratio', 'outRatioPercent', 10],
|
|
9
|
+
['LighthouseOuttraffic', 'outMbps', 10],
|
|
10
|
+
['LighthouseIntraffic', 'inMbps', 10],
|
|
11
|
+
['CpuUsage', 'cpuPercent', 10],
|
|
12
|
+
['MemUsage', 'memoryPercent', 10],
|
|
13
|
+
['DiskUsage', 'diskPercent', 60],
|
|
14
|
+
['CpuLoad1', 'cpuLoad1', 10],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function latestPoint(result) {
|
|
18
|
+
const point = result?.DataPoints?.[0];
|
|
19
|
+
if (!point?.Values?.length || !point?.Timestamps?.length) return null;
|
|
20
|
+
const index = point.Values.length - 1;
|
|
21
|
+
const value = Number(point.Values[index]);
|
|
22
|
+
const timestamp = Number(point.Timestamps[index]);
|
|
23
|
+
return Number.isFinite(value) && Number.isFinite(timestamp)
|
|
24
|
+
? { value, sampledAt: new Date(timestamp * 1000).toISOString() } : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function startAdminCloud({ credentialFile = process.env.CREDENTIALS_DIRECTORY
|
|
28
|
+
? `${process.env.CREDENTIALS_DIRECTORY}/tencent-monitor.json` : null,
|
|
29
|
+
metricIntervalMs = 60000, trafficIntervalMs = 300000 } = {}) {
|
|
30
|
+
if (!credentialFile) return { read: () => ({ configured: false }), close() {} };
|
|
31
|
+
const { secretId, secretKey } = JSON.parse(fs.readFileSync(credentialFile, 'utf8'));
|
|
32
|
+
if (!secretId || !secretKey) throw new Error('Tencent monitor credentials are incomplete');
|
|
33
|
+
const credential = { secretId, secretKey };
|
|
34
|
+
const profile = { httpProfile: { reqTimeout: 8 } };
|
|
35
|
+
const monitor = new CommonClient('monitor.tencentcloudapi.com', '2018-07-24',
|
|
36
|
+
{ credential, region: REGION, profile });
|
|
37
|
+
const lighthouse = new CommonClient('lighthouse.tencentcloudapi.com', '2020-03-24',
|
|
38
|
+
{ credential, region: REGION, profile });
|
|
39
|
+
let closed = false;
|
|
40
|
+
let metricBusy = false;
|
|
41
|
+
let trafficBusy = false;
|
|
42
|
+
const current = { configured: true, metricAvailable: false, trafficAvailable: false,
|
|
43
|
+
outRatioPercent: null, outMbps: null, inMbps: null, metricSampledAt: null,
|
|
44
|
+
cpuPercent: null, memoryPercent: null, diskPercent: null, cpuLoad1: null,
|
|
45
|
+
trafficUsedBytes: null, trafficTotalBytes: null, trafficRemainingBytes: null,
|
|
46
|
+
trafficStartAt: null, trafficEndAt: null, trafficSampledAt: null };
|
|
47
|
+
|
|
48
|
+
async function sampleMetrics() {
|
|
49
|
+
if (metricBusy || closed) return;
|
|
50
|
+
metricBusy = true;
|
|
51
|
+
const end = new Date();
|
|
52
|
+
const base = { Namespace: 'QCE/LIGHTHOUSE',
|
|
53
|
+
StartTime: new Date(end.getTime() - 180000).toISOString(), EndTime: end.toISOString(),
|
|
54
|
+
Instances: [{ Dimensions: [{ Name: 'InstanceId', Value: INSTANCE_ID }] }] };
|
|
55
|
+
try {
|
|
56
|
+
const values = await Promise.allSettled(METRICS.map(([MetricName, , Period]) =>
|
|
57
|
+
monitor.request('GetMonitorData', { ...base, MetricName, Period })));
|
|
58
|
+
if (closed) return;
|
|
59
|
+
const samples = values.map(result => result.status === 'fulfilled' ? latestPoint(result.value) : null);
|
|
60
|
+
for (let index = 0; index < METRICS.length; index++) {
|
|
61
|
+
const [, field] = METRICS[index];
|
|
62
|
+
const value = samples[index]?.value ?? null;
|
|
63
|
+
current[field] = value !== null && (field === 'outMbps' || field === 'inMbps') ? value * 8 : value;
|
|
64
|
+
}
|
|
65
|
+
current.metricSampledAt = samples.reduce((latest, sample) => sample?.sampledAt > latest
|
|
66
|
+
? sample.sampledAt : latest, '') || null;
|
|
67
|
+
current.metricAvailable = Boolean(current.metricSampledAt);
|
|
68
|
+
} catch {
|
|
69
|
+
current.metricAvailable = false;
|
|
70
|
+
} finally { metricBusy = false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function sampleTraffic() {
|
|
74
|
+
if (trafficBusy || closed) return;
|
|
75
|
+
trafficBusy = true;
|
|
76
|
+
try {
|
|
77
|
+
const data = await lighthouse.request('DescribeInstancesTrafficPackages', { InstanceIds: [INSTANCE_ID] });
|
|
78
|
+
if (closed) return;
|
|
79
|
+
const packages = data?.InstanceTrafficPackageSet?.find(item => item.InstanceId === INSTANCE_ID)?.TrafficPackageSet || [];
|
|
80
|
+
const active = packages.find(item => Date.parse(item.StartTime) <= Date.now()
|
|
81
|
+
&& Date.parse(item.EndTime) >= Date.now()) || packages[0];
|
|
82
|
+
current.trafficUsedBytes = active?.TrafficUsed ?? null;
|
|
83
|
+
current.trafficTotalBytes = active?.TrafficPackageTotal ?? null;
|
|
84
|
+
current.trafficRemainingBytes = active?.TrafficPackageRemaining ?? null;
|
|
85
|
+
current.trafficStartAt = active?.StartTime ?? null;
|
|
86
|
+
current.trafficEndAt = active?.EndTime ?? null;
|
|
87
|
+
current.trafficSampledAt = active ? new Date().toISOString() : null;
|
|
88
|
+
current.trafficAvailable = Boolean(active);
|
|
89
|
+
} catch {
|
|
90
|
+
current.trafficAvailable = false;
|
|
91
|
+
} finally { trafficBusy = false; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
void sampleMetrics();
|
|
95
|
+
void sampleTraffic();
|
|
96
|
+
const metricTimer = setInterval(sampleMetrics, metricIntervalMs);
|
|
97
|
+
const trafficTimer = setInterval(sampleTraffic, trafficIntervalMs);
|
|
98
|
+
metricTimer.unref();
|
|
99
|
+
trafficTimer.unref();
|
|
100
|
+
return { read: () => ({ ...current }), close() { closed = true; clearInterval(metricTimer); clearInterval(trafficTimer); } };
|
|
101
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function adminPage() {
|
|
2
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><meta name="theme-color" content="#f5f7fa"><title>Code Relax 管理</title>
|
|
3
|
+
<style>@media(max-width:600px){body main{padding:12px 12px 40px}header.page-head{align-items:center;gap:8px;margin-bottom:8px}header.page-head h1{font-size:20px;line-height:1.2;margin:0}main .nav{gap:0;margin-bottom:12px}main .nav button{padding:8px 11px}main .stack,main .grid{gap:10px}main .card{padding:12px}main .hero strong{font-size:20px}main .throughput{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:10px}main .metrics{gap:6px}main .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:6px;min-width:0;padding:9px 10px}main .metric strong{font-size:17px;margin:0;white-space:nowrap}main .metric span{font-size:11px}main .card-head{margin-bottom:10px}}</style>
|
|
4
|
+
<style>@media(max-width:600px){main .throughput .metric strong{font-size:17px}}</style>
|
|
5
|
+
<style>.metric.warn{border-color:#946d38;background:#352a1b}.metric.warn strong{color:#ffd192}.metric.critical{border-color:#a65b58;background:#38211f}.metric.critical strong{color:#ffb1a9}.state.critical{background:#72342e;color:#ffe1dc}#trafficTimeline.critical .quota-fill{background:#d46a60}</style>
|
|
6
|
+
<style>.quota-timeline{display:grid;gap:9px;margin-top:18px}.quota-timeline[hidden]{display:none}.quota-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;color:#b7c8ba;font-size:12px;font-variant-numeric:tabular-nums}.quota-heading strong{font-size:13px}.quota-pace{font-weight:700;color:#7bc993}.quota-pace.ahead{color:#e9b46f}.quota-track{position:relative;height:13px;border:1px solid #526154;border-radius:7px;background:#5c6e6030}.quota-fill{position:absolute;inset:0 auto 0 0;border-radius:6px;background:#48ae78}.quota-timeline.ahead .quota-fill{background:#d39b4c}.quota-now{position:absolute;top:-5px;bottom:-5px;width:2px;background:#d8e3db;box-shadow:0 0 0 1px #162019;transform:translateX(-1px)}.quota-now:before{position:absolute;top:-1px;left:50%;width:7px;height:7px;background:#d8e3db;transform:translate(-50%,-50%) rotate(45deg);content:""}.quota-period{display:flex;justify-content:space-between;gap:12px;color:#91a394;font-size:11px}.quota-period span:last-child{text-align:right}</style>
|
|
7
|
+
<style>
|
|
8
|
+
:root{color-scheme:only light;font:14px/1.5 system-ui,"Microsoft YaHei",sans-serif;background:#f5f7fa;color:#17212b}*{box-sizing:border-box}body{margin:0}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid transparent;border-radius:9px;padding:9px 14px;background:#397e58;color:white;font-weight:600}button:hover{background:#46966b}button:disabled{opacity:.45;cursor:default}button.secondary{background:#26352c;color:#c8d7cd;border-color:#405548}button.danger{background:transparent;color:#eaa29c;border-color:#734947}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid #8ad2a0;outline-offset:2px}
|
|
9
|
+
main{max-width:1180px;margin:auto;padding:28px 24px 70px}h1{font-size:29px;line-height:1.2;margin:6px 0 8px}h2{font-size:18px;margin:0}h3{font-size:15px;margin:0}p{margin:0}.eyebrow{color:#83b598;letter-spacing:.12em;font-size:11px;font-weight:700}.subtle,.hint,.note{color:#a3b4a8}.hint,.note{font-size:12px}.note{margin-top:15px}.page-head{display:flex;align-items:end;justify-content:space-between;gap:16px;margin-bottom:22px}.page-head .subtle{margin-top:6px}.nav{display:flex;gap:7px;border-bottom:1px solid #34463a;margin-bottom:22px;overflow:auto}.nav button{border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;color:#b6c9bb;padding:12px 17px;white-space:nowrap}.nav button[aria-current=page]{color:#b9efca;border-bottom-color:#8ad2a0;background:#1a2b20}.page[hidden]{display:none}.stack{display:grid;gap:18px}.grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(290px,.75fr);gap:18px;align-items:start}.card{background:#19241d;border:1px solid #34463a;border-radius:16px;padding:22px;box-shadow:0 12px 30px #080e0a24}.card-head{margin-bottom:18px}.hero{display:flex;align-items:start;justify-content:space-between;gap:18px;flex-wrap:wrap}.hero strong{font-size:26px}.state{border-radius:100px;padding:5px 10px;font-size:12px;font-weight:700;color:#c9f2d5;background:#28583b}.state.warn{background:#62491c;color:#ffe4a8}.state.down{background:#63302c;color:#ffd1c8}.throughput,.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.throughput{margin-top:20px}.metrics{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{background:#111b15;border:1px solid #2b3c30;border-radius:11px;padding:14px;color:#9eafa3;font-size:12px}.metric strong{display:block;color:#f0f6f0;font-size:25px;line-height:1.2;margin-bottom:5px;font-variant-numeric:tabular-nums}.metric small{display:block;color:#80b893}.throughput .metric strong{font-size:32px}.detail{display:grid;gap:10px}.detail div{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid #2b3c30;padding-bottom:10px}.detail div:last-child{border:0;padding:0}.field{display:grid;gap:7px;margin:15px 0}.field label{font-size:13px;font-weight:600;color:#c5d7c9}input,textarea{width:100%;background:#101913;border:1px solid #45614d;border-radius:9px;color:#f1f7f1;padding:10px 12px}textarea{min-height:145px;resize:vertical}.actions{display:flex;flex-wrap:wrap;gap:9px;align-items:center}.current{border:1px solid #385841;background:#112017;border-radius:11px;padding:15px;margin-bottom:18px}.current.empty{border-color:#34463a;background:#111b15}.badge{display:inline-block;background:#28583b;color:#c9f2d5;border-radius:100px;padding:2px 8px;font-size:11px;font-weight:700}.current h3,.history h3{margin:8px 0 4px;overflow-wrap:anywhere}.current p,.history p{white-space:pre-wrap;overflow-wrap:anywhere;color:#c1d1c5}.date{font-size:12px;color:#94a99a}.history{display:grid;gap:10px}.history-item{border:1px solid #34463a;background:#141e17;border-radius:11px;padding:15px}.history-item.active{border-color:#4d865e}.history-item p{margin:8px 0 12px;max-height:4.5em;overflow:hidden}.history-item .actions button{padding:6px 10px;font-size:12px}.empty-note{padding:24px;border:1px dashed #415748;border-radius:10px;color:#a3b4a8;text-align:center}.status{min-height:24px;margin-top:16px;color:#96d4a9}.status.error{color:#f0aaa2}@media(max-width:800px){main{padding:24px 16px 50px}.grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:500px){.page-head{align-items:start}.card{padding:17px}.throughput{grid-template-columns:1fr}.metric{padding:10px}.metric strong{font-size:21px}}
|
|
10
|
+
</style><style>
|
|
11
|
+
.announcement-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:18px}.announcement-toolbar h2{font-size:20px}.announcement-controls{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.announcement-controls input{width:min(230px,100%)}.announcement-controls select{width:auto;min-width:108px}select{font:inherit;background:#101913;border:1px solid #45614d;border-radius:9px;color:#f1f7f1;padding:10px 12px}.announcement-list{display:grid;gap:9px}.announcement-row{display:grid;grid-template-columns:minmax(0,1fr) 90px 145px minmax(220px,auto);align-items:center;gap:14px;border:1px solid #34463a;border-radius:11px;padding:14px;background:#141e17}.announcement-row.active{border-color:#4d865e}.announcement-row h3{overflow-wrap:anywhere}.announcement-row p{color:#a3b4a8;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.announcement-row .actions{justify-content:flex-end;gap:6px}.announcement-row .actions button{padding:6px 9px;font-size:12px}.announcement-row .date{font-variant-numeric:tabular-nums}.announcement-status{display:inline-block;width:max-content;padding:3px 8px;border-radius:100px;font-size:11px;font-weight:700;color:#cad7ce;background:#334038}.announcement-status.active{background:#28583b;color:#c9f2d5}.announcement-status.draft{background:#4d462c;color:#ffe4a8}.announcement-empty{padding:32px;text-align:center;color:#a3b4a8}dialog{width:min(570px,calc(100% - 28px));max-height:calc(100vh - 36px);overflow:auto;background:#19241d;color:#edf4ee;border:1px solid #45614d;border-radius:16px;padding:22px;box-shadow:0 24px 70px #050905bb}dialog::backdrop{background:#050905bb}.editor-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.editor-head button{padding:4px 10px}.editor-actions{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:18px}.editor-actions[hidden]{display:none}dialog textarea{min-height:200px}dialog input:disabled,dialog textarea:disabled{opacity:.8}.hero-meta{display:flex;align-items:center;gap:10px}.hero-meta .hint{white-space:nowrap}.quota-card .card-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.quota-card .card-head .hint{white-space:nowrap}@media(max-width:800px){.announcement-row{grid-template-columns:minmax(0,1fr) auto;gap:8px}.announcement-row .date{grid-column:1}.announcement-row .actions{grid-column:2;grid-row:2;align-self:end}.announcement-row .announcement-status{grid-column:2;grid-row:1}.announcement-toolbar{align-items:stretch}.announcement-controls{width:100%}.announcement-controls input{flex:1;min-width:110px}}@media(max-width:500px){.announcement-row{padding:11px}.announcement-row .actions button{padding:5px 7px;font-size:11px}.announcement-controls select{min-width:96px}.hero{align-items:center;flex-wrap:nowrap}.hero strong{font-size:20px}.hero-meta{gap:6px}.hero-meta .hint{font-size:11px}.hero-meta .state{padding:3px 7px}}
|
|
12
|
+
</style><style>
|
|
13
|
+
:root{color-scheme:only light;background:#f5f7fa;color:#17212b}body{min-height:100vh;background:radial-gradient(circle at 16% 15%,#cfe6d7aa 0,transparent 28%),radial-gradient(circle at 86% 76%,#dbece0aa 0,transparent 26%),linear-gradient(#1f4a2d0b 1px,transparent 1px),linear-gradient(90deg,#1f4a2d0b 1px,transparent 1px),#f5f7fa;background-size:auto,auto,32px 32px,32px 32px,auto}
|
|
14
|
+
button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{background:#cce5d4;color:#194d31;border-color:#84b995}button.secondary{background:#f4f8f5;color:#315f42;border-color:#c8daca}button.secondary:hover{background:#e8f2eb;color:#235d3a;border-color:#a9cbb4}button.danger{background:#fff8f6;color:#a34c43;border-color:#e6c5c0}button.danger:hover{background:#fdece8;color:#993b32;border-color:#dba7a0}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline-color:#4b9b66}
|
|
15
|
+
.subtle,.hint,.note{color:#607168}.page-head{justify-content:flex-start;align-items:center;gap:26px;margin-bottom:18px}.page-head h1{flex:none;color:#26332b}.page-head .nav{min-width:0;gap:4px;margin:0;border:0}.page-head .nav button{flex:none}.nav button{color:#607168}.nav button:hover{background:#eef5f0;color:#286e43}.nav button[aria-current=page]{color:#286e43;border-bottom-color:#68b582;background:#e4f1e8}.nav button[aria-current=page]:hover{background:#d8ebdd;color:#235d3a}.card{background:#ffffffee;border-color:#d5ddd8;box-shadow:0 10px 24px #263a2d0a}.metric{background:#f8faf8;border-color:#dce5df;color:#607168}.metric strong{color:#26332b}.metric small{color:#43865b}.metric.warn{background:#fff6e7;border-color:#e8c890}.metric.warn strong{color:#985d16}.metric.critical{background:#fff0ec;border-color:#dfaaa1}.metric.critical strong{color:#a5443b}
|
|
16
|
+
.state,.badge,.announcement-status.active{background:#e4f1e8;color:#286e43}.state.warn,.announcement-status.draft{background:#fff1d8;color:#895711}.state.down,.state.critical{background:#fbe6e2;color:#a13e35}.traffic-usage{color:#52645a;font-size:13px;font-variant-numeric:tabular-nums}.quota-card .quota-timeline{margin-top:12px}.quota-heading{color:#52645a}.quota-heading .warn{color:#985d16}.quota-heading .critical{color:#a5443b}.quota-pace{color:#28764a}.quota-pace.ahead{color:#a87326}.quota-track{border-color:#cfdbd2;background:#e9f0eb}.quota-fill{background:#68b582}.quota-timeline.ahead .quota-fill,.quota-timeline.warn .quota-fill{background:#d39b4c}.quota-now{background:#52645a;box-shadow:0 0 0 1px #fff}.quota-now:before{background:#52645a}.quota-period{color:#77857d}
|
|
17
|
+
.field label{color:#3b5142}input,textarea,select,.announcement-controls select{background:#fff;border-color:#c6d4cb;color:#26332b}input::placeholder,textarea::placeholder{color:#89988d}.date{color:#77857d}.announcement-row{background:#fff;border-color:#d5ddd8}.announcement-row.active{border-color:#a9c6b3;box-shadow:inset 3px 0 #68b582}.announcement-row p{color:#607168}.announcement-status{background:#edf1ee;color:#52645a}.announcement-empty{color:#607168}dialog{background:#fff;color:#26332b;border-color:#c6d4cb;box-shadow:0 24px 70px #263a2d33}dialog::backdrop{background:#172c20a8}.status{color:#28764a}.status.error{color:#a5443b}.relay-heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}.overview-sample{display:flex;justify-content:flex-end}.throughput{margin-top:12px}.throughput .metric{padding:10px 12px}.throughput .metric strong{font-size:20px}@media(max-width:600px){header.page-head{gap:8px}header.page-head h1{font-size:18px}.page-head .nav{gap:2px}.page-head .nav button{padding:7px 6px;font-size:12px}main .throughput{margin-top:8px}main .throughput .metric{padding:7px 8px}main .throughput .metric strong{font-size:14px}}
|
|
18
|
+
</style><style>
|
|
19
|
+
#metrics{grid-template-columns:repeat(5,minmax(0,1fr))}.relay-heading{margin-bottom:14px}.resource-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.resource-head .hint{white-space:nowrap}.resource-card .throughput{border-top:1px solid #dce5df;padding-top:10px;margin-top:12px}.resource-card .throughput .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px}.resource-card .throughput .metric strong{font-size:16px;margin:0;white-space:nowrap}
|
|
20
|
+
@media(max-width:800px){#metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
21
|
+
@media(max-width:600px){.resource-card .throughput .metric strong{font-size:14px}}
|
|
22
|
+
</style></head><body><main>
|
|
23
|
+
<header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button></nav></header>
|
|
24
|
+
<section id="overview" class="page stack"><section class="card" aria-label="中继连接与转发"><div class="relay-heading"><h2>中继连接与转发</h2><span id="healthText" class="state">读取中</span></div><div id="metrics" class="metrics"></div></section><div class="grid"><section class="card resource-card" aria-label="服务器资源"><div class="card-head resource-head"><h2>服务器资源</h2><span id="sampleTime" class="hint">等待采样</span></div><div id="serverMetrics" class="metrics"></div><div class="throughput" id="throughput"></div></section><section class="card quota-card"><div class="card-head"><h2>流量包</h2><span id="trafficNote" class="hint">等待更新</span></div><p id="trafficUsage" class="traffic-usage">—</p><div id="trafficTimeline" class="quota-timeline" hidden><div class="quota-heading"><strong id="trafficRemaining">流量剩余 —</strong><span id="trafficPace" class="quota-pace">—×</span><span id="timeRemaining">时间剩余 —</span></div><div id="quotaTrack" class="quota-track" role="img"><span id="quotaFill" class="quota-fill"></span><span id="quotaNow" class="quota-now"></span></div><div class="quota-period"><span id="trafficEnd">到期 —</span><span id="trafficStart">本期开始 —</span></div></div></section></div></section>
|
|
25
|
+
<section id="announcements" class="page" hidden><section class="card"><div class="announcement-toolbar"><div><h2>公告管理</h2><p id="announcementCount" class="hint">正在读取</p></div><button id="newAnnouncement" type="button">新建公告</button></div><div class="announcement-controls"><input id="announcementSearch" type="search" aria-label="搜索公告" placeholder="搜索标题或正文"><select id="announcementFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="active">发布中</option><option value="draft">草稿</option><option value="withdrawn">已撤下</option></select></div><div id="announcementList" class="announcement-list" style="margin-top:14px"></div></section></section>
|
|
26
|
+
<dialog id="announcementEditor" aria-labelledby="editorHeading"><div class="editor-head"><h2 id="editorHeading">新建公告</h2><button id="closeEditor" class="secondary" type="button" aria-label="关闭">关闭</button></div><div class="field"><label for="title">标题</label><input id="title" maxlength="80" placeholder="输入公告标题"></div><div class="field"><label for="message">正文</label><textarea id="message" maxlength="2000" placeholder="输入公告内容"></textarea></div><p id="editorNote" class="hint"></p><p id="editorError" class="status error" role="alert" hidden></p><div id="editorActions" class="editor-actions"><button id="saveDraft" class="secondary" type="button">保存草稿</button><button id="publish" type="button">发布公告</button></div></dialog>
|
|
27
|
+
<section id="settings" class="page stack" hidden><section class="card"><div class="card-head"><h2>服务器转发容量</h2><p class="hint">只限制经 WSS 转发的普通会话,WebRTC 直连与白名单转发不占用此额度</p></div><div class="field"><label for="limit">最大并发会话</label><input id="limit" type="number" min="1" max="256"></div><button id="saveLimit" type="button">保存设置</button><p class="note">当前上限是人工配置值,不随 CPU 或带宽自动改变。</p></section></section><p id="result" class="status" role="status" aria-live="polite"></p>
|
|
28
|
+
</main><script>
|
|
29
|
+
const el=id=>document.getElementById(id);let snapshot=null;
|
|
30
|
+
const request=async(path,options={})=>{const response=await fetch(path,options);const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
|
|
31
|
+
function setResult(message,error=false){el('result').textContent=message;el('result').classList.toggle('error',error)}
|
|
32
|
+
function node(tag,className,value){const item=document.createElement(tag);if(className)item.className=className;if(value!==undefined)item.textContent=value;return item}
|
|
33
|
+
function metric(value,label,extra,tone){const item=node('div','metric'+(tone?' '+tone:''));item.append(node('strong','',value),node('span','',label));if(extra)item.append(node('small','',extra));return item}
|
|
34
|
+
function toneAbove(value,warn,critical){return typeof value==='number'&&Number.isFinite(value)?value>=critical?'critical':value>=warn?'warn':'':''}
|
|
35
|
+
function toneBelow(value,warn,critical){return typeof value==='number'&&Number.isFinite(value)?value<=critical?'critical':value<=warn?'warn':'':''}
|
|
36
|
+
function number(value,digits=0){return typeof value==='number'&&Number.isFinite(value)?value.toFixed(digits):'—'}
|
|
37
|
+
function date(value){return new Date(value).toLocaleString('zh-CN')}
|
|
38
|
+
function renderTrafficTimeline(cloud,available){const start=Date.parse(cloud.trafficStartAt);const end=Date.parse(cloud.trafficEndAt);const total=Number(cloud.trafficTotalBytes);const remaining=Number(cloud.trafficRemainingBytes);const valid=available&&Number.isFinite(start)&&Number.isFinite(end)&&end>start&&cloud.trafficTotalBytes!=null&&cloud.trafficRemainingBytes!=null&&total>0&&Number.isFinite(remaining);const timeline=el('trafficTimeline');timeline.hidden=!valid;if(!valid)return;
|
|
39
|
+
const clamp=value=>Math.max(0,Math.min(100,value));const quotaLeft=clamp(remaining/total*100);const timeLeft=clamp((end-Date.now())/(end-start)*100);const used=100-quotaLeft;const elapsed=100-timeLeft;const pace=elapsed>0?used/elapsed:null;
|
|
40
|
+
timeline.classList.toggle('ahead',pace!==null&&pace>1);timeline.classList.toggle('warn',quotaLeft<=20);timeline.classList.toggle('critical',quotaLeft<=10);el('trafficRemaining').textContent='流量剩余 '+number(quotaLeft,1)+'%';el('trafficRemaining').className=toneBelow(quotaLeft,20,10);el('timeRemaining').textContent='时间剩余 '+number(timeLeft,1)+'%';el('trafficPace').textContent=pace===null?'—×':number(pace,2)+'×';el('trafficPace').classList.toggle('ahead',pace!==null&&pace>1);
|
|
41
|
+
el('quotaFill').style.width=quotaLeft+'%';el('quotaNow').style.left=timeLeft+'%';el('quotaTrack').setAttribute('aria-label','流量剩余 '+number(quotaLeft,1)+'%,时间剩余 '+number(timeLeft,1)+'%');el('trafficStart').textContent='本期开始 '+date(cloud.trafficStartAt);el('trafficEnd').textContent='到期 '+date(cloud.trafficEndAt)}
|
|
42
|
+
function renderOverview(state){const cloud=state.cloud||{};const relay=state.relayAvailable!==false;const cloudFresh=cloud.metricAvailable&&Date.now()-Date.parse(cloud.metricSampledAt)<180000;const trafficFresh=cloud.trafficAvailable&&Date.now()-Date.parse(cloud.trafficSampledAt)<900000;const occupancy=state.maxRelaySessions?state.activeRelaySessions/state.maxRelaySessions:0;const high=occupancy>=.9||(cloudFresh&&(cloud.cpuPercent>=85||cloud.memoryPercent>=90||cloud.diskPercent>=90||cloud.outRatioPercent>=90));const caution=occupancy>=.75||(cloudFresh&&(cloud.cpuPercent>=65||cloud.memoryPercent>=80||cloud.diskPercent>=80||cloud.outRatioPercent>=70));const label=!relay?'中继不可用':high?'压力较高':!cloudFresh?'云监控数据过期':caution?'需要关注':'压力正常';
|
|
43
|
+
el('healthText').textContent=label;el('healthText').className='state'+(!relay||!cloudFresh&&!high?' down':high?' critical':caution?' warn':'');el('sampleTime').textContent=cloudFresh?'采样时间 '+new Date(cloud.metricSampledAt).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'等待采样';
|
|
44
|
+
el('throughput').replaceChildren(metric(cloudFresh?number(cloud.outMbps,2)+' Mbps':'—','公网发送'),metric(cloudFresh?number(cloud.inMbps,2)+' Mbps':'—','公网接收'));
|
|
45
|
+
el('serverMetrics').replaceChildren(metric(cloudFresh?number(cloud.cpuPercent,1)+'%':'—','CPU 使用率',null,cloudFresh?toneAbove(cloud.cpuPercent,65,85):''),metric(cloudFresh?number(cloud.memoryPercent,1)+'%':'—','内存使用率',null,cloudFresh?toneAbove(cloud.memoryPercent,80,90):''),metric(cloudFresh?number(cloud.diskPercent,1)+'%':'—','系统盘使用率',null,cloudFresh?toneAbove(cloud.diskPercent,80,90):''),metric(cloudFresh?number(cloud.cpuLoad1,2):'—','1 分钟负载'),metric(cloudFresh?number(cloud.outRatioPercent,1)+'%':'—','出口带宽使用率',null,cloudFresh?toneAbove(cloud.outRatioPercent,70,90):''));
|
|
46
|
+
const gb=value=>value==null?'—':number(Number(value)/1073741824,2)+' GB';
|
|
47
|
+
el('trafficUsage').textContent=trafficFresh?'已用 '+gb(cloud.trafficUsedBytes)+' / '+gb(cloud.trafficTotalBytes):'流量暂不可用';
|
|
48
|
+
el('trafficNote').textContent=trafficFresh?'更新时间 '+new Date(cloud.trafficSampledAt).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'暂不可用';
|
|
49
|
+
renderTrafficTimeline(cloud,trafficFresh);
|
|
50
|
+
el('metrics').replaceChildren(metric(number(state.activeRelaySessions)+' / '+number(state.maxRelaySessions),'受限转发',null,state.maxRelaySessions?toneAbove(state.activeRelaySessions/state.maxRelaySessions,.75,.9):''),metric(number(state.activeExemptRelaySessions),'白名单转发'),metric(number(state.websocketConnections),'WebSocket 连接'),metric(number(state.onlineComputers),'在线工作站'),metric(number(state.activePhones),'活动手机'))}
|
|
51
|
+
let editorItem=null,editorMode='new',writing=false;
|
|
52
|
+
function openEditor(item=null,duplicate=false){editorItem=duplicate?null:item;editorMode=duplicate?'new':item?.status==='draft'?'draft':item?'view':'new';el('editorHeading').textContent=duplicate?'复制为新公告':editorMode==='draft'?'编辑草稿':editorMode==='view'?'查看公告':'新建公告';el('title').value=item?.title||'';el('message').value=item?.message||'';el('title').disabled=editorMode==='view';el('message').disabled=editorMode==='view';el('editorActions').hidden=editorMode==='view';el('editorNote').textContent=editorMode==='view'?'已发布内容只读;可复制为新公告再修改。':'发布后会立即替换当前公告并通知在线设备。';el('editorError').hidden=true;el('announcementEditor').showModal();if(editorMode!=='view')el('title').focus()}
|
|
53
|
+
function action(label,className,handler,disabled=false){const button=node('button',className,label);button.type='button';button.disabled=disabled;button.addEventListener('click',handler);return button}
|
|
54
|
+
function renderAnnouncements(state){const available=state.relayAvailable!==false;const items=state.announcements||[];const filter=el('announcementFilter').value;const query=el('announcementSearch').value.trim().toLocaleLowerCase();const visible=items.filter(item=>(filter==='all'||item.status===filter)&&(!query||(item.title+' '+item.message).toLocaleLowerCase().includes(query)));el('newAnnouncement').disabled=!available;el('announcementCount').textContent=available?'共 '+items.length+' 条 · 发布中 '+items.filter(item=>item.status==='active').length+' 条':'中继暂不可用';const list=el('announcementList');list.replaceChildren();if(!visible.length){list.append(node('div','announcement-empty',available?items.length?'没有符合条件的公告':'暂无公告,点击“新建公告”开始':'暂时无法读取公告'));return}
|
|
55
|
+
for(const item of visible){const row=node('article','announcement-row'+(item.status==='active'?' active':''));const summary=node('div');summary.append(node('h3','',item.title),node('p','',item.message));const status=node('span','announcement-status '+item.status,{active:'发布中',draft:'草稿',withdrawn:'已撤下'}[item.status]||'未知');const when=node('span','date',(item.status==='draft'?'修改 ':'发布 ')+date(item.status==='draft'?item.updatedAt:item.publishedAt));const actions=node('div','actions');const path='/admin/api/announcements/'+encodeURIComponent(item.id);
|
|
56
|
+
if(item.status==='draft'){actions.append(action('编辑','secondary',()=>openEditor(item)),action('发布','',()=>{if(!snapshot.announcement||confirm('发布后将替换当前公告,确定发布?'))write('PUT',path+'/publish',{},'公告已发布')},!available),action('删除','danger',()=>{if(confirm('确定删除这条草稿?'))write('DELETE',path,{},'草稿已删除')},!available))}
|
|
57
|
+
else if(item.status==='active'){actions.append(action('查看','secondary',()=>openEditor(item)),action('撤下','danger',()=>{if(confirm('确定撤下当前公告?'))write('PUT',path+'/withdraw',{},'公告已撤下')},!available))}
|
|
58
|
+
else{actions.append(action('查看','secondary',()=>openEditor(item)),action('复制','secondary',()=>openEditor(item,true),!available),action('删除','danger',()=>{if(confirm('确定删除这条已撤下的公告?'))write('DELETE',path,{},'公告记录已删除')},!available))}
|
|
59
|
+
row.append(summary,status,when,actions);list.append(row)}}
|
|
60
|
+
function render(state){snapshot=state;renderOverview(state);renderAnnouncements(state);el('saveLimit').disabled=state.relayAvailable===false;if(document.activeElement!==el('limit'))el('limit').value=state.maxRelaySessions??''}
|
|
61
|
+
async function load(){try{render(await request('/admin/api/status'));setResult('')}catch(error){setResult(error.message,true)}}
|
|
62
|
+
async function write(method,path,body,message){if(writing)return false;writing=true;try{await request(path,{method,headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify(body)});await load();setResult(message);return true}catch(error){setResult(error.message,true);if(el('announcementEditor').open){el('editorError').textContent=error.message;el('editorError').hidden=false}return false}finally{writing=false}}
|
|
63
|
+
function showPage(page){for(const button of document.querySelectorAll('[data-page]'))button.setAttribute('aria-current',button.dataset.page===page?'page':'false');for(const section of document.querySelectorAll('.page'))section.hidden=section.id!==page;history.replaceState(null,'','#'+page);load()}
|
|
64
|
+
for(const button of document.querySelectorAll('[data-page]'))button.addEventListener('click',()=>showPage(button.dataset.page));
|
|
65
|
+
el('saveLimit').addEventListener('click',()=>write('PUT','/admin/api/settings',{maxRelaySessions:Number(el('limit').value)},'转发容量已保存'));
|
|
66
|
+
el('newAnnouncement').addEventListener('click',()=>openEditor());el('closeEditor').addEventListener('click',()=>el('announcementEditor').close());el('announcementSearch').addEventListener('input',()=>{if(snapshot)renderAnnouncements(snapshot)});el('announcementFilter').addEventListener('change',()=>{if(snapshot)renderAnnouncements(snapshot)});
|
|
67
|
+
async function saveAnnouncement(publish){const title=el('title').value.trim(),message=el('message').value.trim();if(!title||!message){el('editorError').textContent='请填写标题和正文';el('editorError').hidden=false;return}if(publish&&snapshot?.announcement&&snapshot.announcement.id!==editorItem?.id&&!confirm('发布后将替换当前公告,确定发布?'))return;const path=editorMode==='draft'?'/admin/api/announcements/'+encodeURIComponent(editorItem.id):'/admin/api/announcements';const target=publish&&editorMode==='draft'?path+'/publish':path;const body={title,message};if(publish&&editorMode==='new')body.publish=true;const ok=await write('PUT',target,body,publish?'公告已发布':'草稿已保存');if(ok)el('announcementEditor').close()}
|
|
68
|
+
el('saveDraft').addEventListener('click',()=>saveAnnouncement(false));el('publish').addEventListener('click',()=>saveAnnouncement(true));
|
|
69
|
+
showPage(['overview','announcements','settings'].includes(location.hash.slice(1))?location.hash.slice(1):'overview');setInterval(()=>{if(!document.hidden&&!el('overview').hidden)load()},5000);
|
|
70
|
+
</script></body></html>`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { adminPage } from './admin-page.mjs';
|
|
3
|
+
import { startAdminCloud } from './admin-cloud.mjs';
|
|
4
|
+
|
|
5
|
+
function reply(response, status, body, type = 'application/json; charset=utf-8') {
|
|
6
|
+
const bytes = typeof body === 'string' ? body : JSON.stringify(body);
|
|
7
|
+
response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(bytes),
|
|
8
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
|
9
|
+
response.end(bytes);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.0.1:45850' } = {}) {
|
|
13
|
+
const cloud = startAdminCloud();
|
|
14
|
+
const relayOrigin = new URL(relayUrl);
|
|
15
|
+
if (relayOrigin.hostname !== '127.0.0.1' || relayOrigin.protocol !== 'http:' || relayOrigin.pathname !== '/') {
|
|
16
|
+
cloud.close(); throw new Error('Admin relay endpoint must be loopback HTTP');
|
|
17
|
+
}
|
|
18
|
+
const server = http.createServer(async (request, response) => {
|
|
19
|
+
const target = new URL(request.url || '/', 'http://localhost');
|
|
20
|
+
if (!target.pathname.startsWith('/admin')) { reply(response, 404, { error: 'Not found' }); return; }
|
|
21
|
+
if (request.method === 'GET' && target.pathname === '/admin') {
|
|
22
|
+
reply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
|
|
23
|
+
}
|
|
24
|
+
if (request.method === 'GET' && target.pathname === '/admin/api/status') {
|
|
25
|
+
try {
|
|
26
|
+
const upstream = await fetch(new URL(target.pathname, relayOrigin), { signal: AbortSignal.timeout(2000) });
|
|
27
|
+
if (!upstream.ok) throw new Error('Relay status unavailable');
|
|
28
|
+
const state = await upstream.json();
|
|
29
|
+
reply(response, 200, { ...state, relayAvailable: true, cloud: cloud.read() });
|
|
30
|
+
} catch {
|
|
31
|
+
reply(response, 200, { relayAvailable: false, cloud: cloud.read(), maxRelaySessions: null,
|
|
32
|
+
activeRelaySessions: null, activeExemptRelaySessions: null, websocketConnections: null,
|
|
33
|
+
onlineComputers: null, activePhones: null, announcement: null, announcements: [] });
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!target.pathname.startsWith('/admin/api/') || !['PUT', 'DELETE'].includes(request.method || '')) {
|
|
38
|
+
reply(response, 404, { error: 'Not found' }); return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const chunks = [];
|
|
42
|
+
let size = 0;
|
|
43
|
+
for await (const chunk of request) {
|
|
44
|
+
size += chunk.length;
|
|
45
|
+
if (size > 4096) { reply(response, 413, { error: 'Admin request too large' }); return; }
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
}
|
|
48
|
+
const upstream = await fetch(new URL(target.pathname, relayOrigin), {
|
|
49
|
+
method: request.method, signal: AbortSignal.timeout(3000),
|
|
50
|
+
headers: { 'Content-Type': request.headers['content-type'] || '',
|
|
51
|
+
'X-Code-Relax-Admin': request.headers['x-code-relax-admin'] || '' },
|
|
52
|
+
body: Buffer.concat(chunks),
|
|
53
|
+
});
|
|
54
|
+
reply(response, upstream.status, await upstream.text());
|
|
55
|
+
} catch { reply(response, 503, { error: '中继服务暂不可用' }); }
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
59
|
+
} catch (error) { cloud.close(); throw error; }
|
|
60
|
+
return { port: server.address().port, async close() { cloud.close(); await new Promise(resolve => server.close(resolve)); } };
|
|
61
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export function relayAdminState(file, initialMaxRelaySessions) {
|
|
6
|
+
let value = { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
|
|
7
|
+
if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
8
|
+
|
|
9
|
+
function save(next) {
|
|
10
|
+
value = validate(next);
|
|
11
|
+
if (!file) return value;
|
|
12
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
13
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
14
|
+
let descriptor;
|
|
15
|
+
try {
|
|
16
|
+
descriptor = fs.openSync(temporary, 'wx', 0o600);
|
|
17
|
+
fs.writeFileSync(descriptor, JSON.stringify(value));
|
|
18
|
+
fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined;
|
|
19
|
+
fs.renameSync(temporary, file);
|
|
20
|
+
if (process.platform !== 'win32') {
|
|
21
|
+
const directory = fs.openSync(path.dirname(file), 'r');
|
|
22
|
+
try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
|
|
23
|
+
}
|
|
24
|
+
} finally {
|
|
25
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
26
|
+
fs.rmSync(temporary, { force: true });
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
read: () => structuredClone(value),
|
|
33
|
+
setMaxRelaySessions(maxRelaySessions) { return save({ ...value, maxRelaySessions }); },
|
|
34
|
+
createAnnouncement(title, message, publish = false) {
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
const record = { id: randomUUID(), title: clean(title, 80), message: clean(message, 2000),
|
|
37
|
+
createdAt: now, updatedAt: now, publishedAt: publish ? now : null, status: publish ? 'active' : 'draft' };
|
|
38
|
+
const announcements = value.announcements.map(item => publish && item.status === 'active'
|
|
39
|
+
? { ...item, status: 'withdrawn', updatedAt: now } : item);
|
|
40
|
+
return save({ ...value, announcement: publish ? publicAnnouncement(record) : value.announcement,
|
|
41
|
+
announcements: [record, ...announcements] });
|
|
42
|
+
},
|
|
43
|
+
updateDraft(id, title, message) {
|
|
44
|
+
const existing = value.announcements.find(item => item.id === id);
|
|
45
|
+
if (!existing || existing.status !== 'draft') throw new Error('Only drafts can be edited');
|
|
46
|
+
return save({ ...value, announcements: value.announcements.map(item => item.id === id
|
|
47
|
+
? { ...item, title: clean(title, 80), message: clean(message, 2000), updatedAt: new Date().toISOString() } : item) });
|
|
48
|
+
},
|
|
49
|
+
publishDraft(id, title, message) {
|
|
50
|
+
const existing = value.announcements.find(item => item.id === id);
|
|
51
|
+
if (!existing || existing.status !== 'draft') throw new Error('Only drafts can be published');
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
const published = { ...existing, title: title === undefined ? existing.title : clean(title, 80),
|
|
54
|
+
message: message === undefined ? existing.message : clean(message, 2000),
|
|
55
|
+
publishedAt: now, updatedAt: now, status: 'active' };
|
|
56
|
+
return save({ ...value, announcement: publicAnnouncement(published),
|
|
57
|
+
announcements: value.announcements.map(item => item.id === id ? published : item.status === 'active'
|
|
58
|
+
? { ...item, status: 'withdrawn', updatedAt: now } : item) });
|
|
59
|
+
},
|
|
60
|
+
withdraw(id) {
|
|
61
|
+
if (!value.announcement || value.announcement.id !== id) throw new Error('Announcement is no longer active');
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
return save({ ...value, announcement: null, announcements: value.announcements.map(item => item.status === 'active'
|
|
64
|
+
? { ...item, status: 'withdrawn', updatedAt: now } : item) });
|
|
65
|
+
},
|
|
66
|
+
deleteAnnouncement(id) {
|
|
67
|
+
if (typeof id !== 'string' || !value.announcements.some(item => item.id === id)) throw new Error('Announcement not found');
|
|
68
|
+
if (value.announcement?.id === id) throw new Error('Withdraw the current announcement before deleting it');
|
|
69
|
+
return save({ ...value, announcements: value.announcements.filter(item => item.id !== id) });
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validate(source) {
|
|
75
|
+
if (!source || ![1, 2, 3].includes(source.version) || !Number.isSafeInteger(source.maxRelaySessions) ||
|
|
76
|
+
source.maxRelaySessions < 1 || source.maxRelaySessions > 256) throw new Error('Invalid relay admin state');
|
|
77
|
+
const announcement = source.announcement == null ? null : validateAnnouncement(source.announcement);
|
|
78
|
+
const announcements = source.version === 1 ? (announcement ? [announcement] : []) : source.announcements;
|
|
79
|
+
if (!Array.isArray(announcements)) throw new Error('Invalid relay announcement history');
|
|
80
|
+
const validated = source.version === 3 ? announcements.map(validateRecord) : announcements.map(item => {
|
|
81
|
+
const old = validateAnnouncement(item);
|
|
82
|
+
return { ...old, createdAt: old.publishedAt, updatedAt: old.publishedAt,
|
|
83
|
+
status: old.id === announcement?.id ? 'active' : 'withdrawn' };
|
|
84
|
+
});
|
|
85
|
+
const active = validated.filter(item => item.status === 'active');
|
|
86
|
+
if (new Set(validated.map(item => item.id)).size !== validated.length || active.length > 1 ||
|
|
87
|
+
(active.length === 0) !== (announcement === null) ||
|
|
88
|
+
(announcement && !active.some(item => item.id === announcement.id &&
|
|
89
|
+
item.title === announcement.title && item.message === announcement.message && item.publishedAt === announcement.publishedAt))) {
|
|
90
|
+
throw new Error('Invalid relay announcement history');
|
|
91
|
+
}
|
|
92
|
+
return { version: 3, maxRelaySessions: source.maxRelaySessions, announcement, announcements: validated };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateAnnouncement(item) {
|
|
96
|
+
if (!item || !/^[\w-]{16,128}$/.test(item.id) || typeof item.publishedAt !== 'string' ||
|
|
97
|
+
!Number.isFinite(Date.parse(item.publishedAt))) throw new Error('Invalid relay announcement');
|
|
98
|
+
return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000), publishedAt: item.publishedAt };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateRecord(item) {
|
|
102
|
+
if (!item || !/^[\w-]{16,128}$/.test(item.id) || !['draft', 'active', 'withdrawn'].includes(item.status) ||
|
|
103
|
+
typeof item.createdAt !== 'string' || !Number.isFinite(Date.parse(item.createdAt)) ||
|
|
104
|
+
typeof item.updatedAt !== 'string' || !Number.isFinite(Date.parse(item.updatedAt)) ||
|
|
105
|
+
(item.status === 'draft' ? item.publishedAt !== null :
|
|
106
|
+
typeof item.publishedAt !== 'string' || !Number.isFinite(Date.parse(item.publishedAt)))) {
|
|
107
|
+
throw new Error('Invalid relay announcement record');
|
|
108
|
+
}
|
|
109
|
+
return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000),
|
|
110
|
+
createdAt: item.createdAt, updatedAt: item.updatedAt, publishedAt: item.publishedAt, status: item.status };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function publicAnnouncement(record) {
|
|
114
|
+
return { id: record.id, title: record.title, message: record.message, publishedAt: record.publishedAt };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function clean(value, limit) {
|
|
118
|
+
if (typeof value !== 'string' || !value.trim() || value.trim().length > limit || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) {
|
|
119
|
+
throw new Error('Invalid announcement text');
|
|
120
|
+
}
|
|
121
|
+
return value.trim();
|
|
122
|
+
}
|
|
@@ -27,6 +27,7 @@ export function connectRelayClient({ relayUrl, room, code, credential, clientId,
|
|
|
27
27
|
else if (message.type === 'lan-candidates') events.emit('lan-candidates', message.candidates);
|
|
28
28
|
else if (message.type === 'official-computer') events.emit('official-computer', message.computer);
|
|
29
29
|
else if (message.type === 'official-computer-saved') events.emit('official-computer-saved', message.computer);
|
|
30
|
+
else if (message.type === 'announcement') events.emit('announcement', message.announcement);
|
|
30
31
|
else if (message.type === 'ready') { clearTimeout(timer); connected = true; resolve({ room, credential: pairedCredential, clientId: pairedClientId }); }
|
|
31
32
|
else if (message.type === 'data') {
|
|
32
33
|
const body = decode(message.payload, false), request = pending.get(body.id);
|
|
@@ -50,6 +51,7 @@ export function connectRelayClient({ relayUrl, room, code, credential, clientId,
|
|
|
50
51
|
});
|
|
51
52
|
},
|
|
52
53
|
send(message) { if (!connected) throw new Error('Relay not ready'); return send(socket, { type: 'data', payload: JSON.stringify(message) }); },
|
|
54
|
+
routeDirect() { if (!connected) throw new Error('Relay not ready'); return send(socket, { type: 'p2p-route', direct: 1 }); },
|
|
53
55
|
recordOfficialComputer(computer) {
|
|
54
56
|
if (!connected || !observe) throw new Error('Metadata channel not ready');
|
|
55
57
|
return send(socket, { type: 'official-computer', ...computer });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import WebSocket from 'ws';
|
|
4
|
-
import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
|
|
4
|
+
import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
|
|
5
5
|
import { createP2pProbe, validProbeSignal } from './p2p-probe.mjs';
|
|
6
6
|
import { createWindowsRouteHelper } from './windows-route-helper.mjs';
|
|
7
7
|
|
|
@@ -59,7 +59,8 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
59
59
|
if (message.type === 'attach') {
|
|
60
60
|
events.emit('attached', { session: message.session, clientId: message.clientId });
|
|
61
61
|
dropLocal(message.session);
|
|
62
|
-
const current = { socket: undefined, ready: false, timer: undefined, p2pProbe: p2pProbe && message.p2pProbe === 1
|
|
62
|
+
const current = { socket: undefined, ready: false, timer: undefined, p2pProbe: p2pProbe && message.p2pProbe === 1,
|
|
63
|
+
bridgeOutbound: Promise.resolve() };
|
|
63
64
|
locals.set(message.session, current);
|
|
64
65
|
let connection;
|
|
65
66
|
try {
|
|
@@ -69,7 +70,13 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
69
70
|
send(remote, { type: 'detach', session: message.session }); dropLocal(message.session); events.emit('detached', { session: message.session });
|
|
70
71
|
events.emit('diagnostic', 'Local Bridge connection not ready'); return;
|
|
71
72
|
}
|
|
72
|
-
const socket = current.socket = new WebSocket(connection.url, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: false });
|
|
73
|
+
const socket = current.socket = new WebSocket(connection.url, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: false });
|
|
74
|
+
current.forwardToBridge = payload => {
|
|
75
|
+
if (locals.get(message.session) !== current || !current.ready) throw new Error('Local Bridge connection not ready');
|
|
76
|
+
const request = decode(payload, false);
|
|
77
|
+
if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
|
|
78
|
+
send(socket, request);
|
|
79
|
+
};
|
|
73
80
|
current.timer = setTimeout(() => socket.terminate(), 5000);
|
|
74
81
|
socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
|
|
75
82
|
socket.on('error', error => events.emit('diagnostic', `Local Bridge: ${error.message}`));
|
|
@@ -84,7 +91,15 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
84
91
|
if (!current.ready) {
|
|
85
92
|
if (response.type !== 'ready') throw new Error('Local Bridge authentication failed');
|
|
86
93
|
clearTimeout(current.timer); current.ready = true; send(remote, { type: 'ready', session: message.session });
|
|
87
|
-
} else
|
|
94
|
+
} else {
|
|
95
|
+
const payload = data.toString();
|
|
96
|
+
current.bridgeOutbound = current.bridgeOutbound.then(async () => {
|
|
97
|
+
const probe = await current.probe;
|
|
98
|
+
if (locals.get(message.session) !== current || !current.ready) return;
|
|
99
|
+
if (!probe?.send?.(payload)) send(remote, { type: 'data', session: message.session, payload });
|
|
100
|
+
});
|
|
101
|
+
void current.bridgeOutbound.catch(error => { events.emit('diagnostic', error.message); socket.terminate(); });
|
|
102
|
+
}
|
|
88
103
|
} catch (error) { events.emit('diagnostic', error.message); socket.terminate(); }
|
|
89
104
|
});
|
|
90
105
|
} else if (message.type === 'p2p-probe' && locals.has(message.session)) {
|
|
@@ -101,7 +116,9 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
101
116
|
signal: signal => {
|
|
102
117
|
if (locals.get(message.session) === current && current.probeId === probeId) send(remote, { type: 'p2p-probe', session: message.session,
|
|
103
118
|
version: 1, probeId, ...signal });
|
|
104
|
-
},
|
|
119
|
+
},
|
|
120
|
+
onData: payload => current.forwardToBridge(payload),
|
|
121
|
+
report: (state, diagnostic) => events.emit('p2p-probe', { state, diagnostic })
|
|
105
122
|
})).catch(() => {
|
|
106
123
|
events.emit('diagnostic', p2pWindowsRouteHelper ? 'Windows RTC 路由辅助不可用' : 'WebRTC probe initialization failed');
|
|
107
124
|
return { receive() {}, close() {} };
|
|
@@ -121,9 +138,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
121
138
|
else if (message.type === 'data' && locals.has(message.session)) {
|
|
122
139
|
const current = locals.get(message.session);
|
|
123
140
|
if (!current.ready) { send(remote, { type: 'detach', session: message.session }); dropLocal(message.session); return; }
|
|
124
|
-
|
|
125
|
-
if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
|
|
126
|
-
send(current.socket, request);
|
|
141
|
+
current.forwardToBridge(message.payload);
|
|
127
142
|
}
|
|
128
143
|
} catch (error) { events.emit('diagnostic', error.message); remote.close(1008, 'Invalid relay message'); }
|
|
129
144
|
});
|
|
@@ -6,8 +6,8 @@ import { connectBridgeRelay } from './connector.mjs';
|
|
|
6
6
|
import { connectRelayClient } from './client.mjs';
|
|
7
7
|
import { secret } from './wire.mjs';
|
|
8
8
|
|
|
9
|
-
export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispatch, remoteRelay, hostName = 'TEST-PC', lanCandidates = [] } = {}) {
|
|
10
|
-
if (
|
|
9
|
+
export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispatch, remoteRelay, hostName = 'TEST-PC', lanCandidates = [], relayOptions = {} } = {}) {
|
|
10
|
+
if (mode !== 'lan-shared') throw new Error('Invalid demo mode');
|
|
11
11
|
const token = secret(), calls = [];
|
|
12
12
|
const bridge = http.createServer((_request, response) => { response.writeHead(404); response.end(); });
|
|
13
13
|
const lan = attachLanSocket(bridge, { token, dispatch: async (request, reply) => {
|
|
@@ -17,12 +17,11 @@ export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispat
|
|
|
17
17
|
if (request.url.split('?')[0] === '/api/threads/demo/events') {
|
|
18
18
|
reply.startEvents(); reply.event('update', { thread: { id: 'demo' }, simulated: true }); return;
|
|
19
19
|
}
|
|
20
|
-
if (request.url === '/api/demo/stop') return reply.json(
|
|
21
|
-
mode === 'lan-shared' ? { stopped: true, simulated: true } : { error: '基础控制不支持停止任务' });
|
|
20
|
+
if (request.url === '/api/demo/stop') return reply.json(200, { stopped: true, simulated: true });
|
|
22
21
|
reply.json(404, { error: '模拟 Bridge 不执行真实任务' });
|
|
23
22
|
} });
|
|
24
23
|
await new Promise(resolve => bridge.listen(0, '127.0.0.1', resolve));
|
|
25
|
-
const relay = remoteRelay ? { ...remoteRelay, async close() {} } : await startSelfRelay({ pairingTtlMs });
|
|
24
|
+
const relay = remoteRelay ? { ...remoteRelay, async close() {} } : await startSelfRelay({ pairingTtlMs, ...relayOptions });
|
|
26
25
|
const bridgeUrl = `ws://127.0.0.1:${bridge.address().port}/api/socket`;
|
|
27
26
|
const identity = remoteRelay ? { id: randomUUID(), secret: secret() } : undefined;
|
|
28
27
|
const clientId = randomUUID(), credential = secret();
|