dsh-remote-plugin 0.6.7 → 0.6.9
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.en.md +31 -25
- package/README.md +30 -24
- package/apk/dsh-remote.apk +0 -0
- package/client.js +11 -9
- package/gateway-stats.cjs +26 -11
- package/gateway.cjs +494 -87
- package/index.mjs +12 -2
- package/package.json +1 -1
- package/public/admin.html +103 -7
- package/public/admin.js +24 -0
- package/public/announcements.json +12 -0
- package/public/app.js +592 -91
- package/public/desktop/desktop.css +126 -19
- package/public/desktop/desktop.html +63 -14
- package/public/desktop/desktop.js +295 -77
- package/public/index.html +145 -41
- package/public/plugin.html +151 -0
- package/public/plugin.js +179 -0
- package/public/styles.css +176 -15
- package/public/theme-vars.css +9 -0
- package/public/update.json +12 -4
- package/public/version.json +1 -1
|
@@ -11,6 +11,16 @@ const LS = {
|
|
|
11
11
|
set(k, v) { try { localStorage.setItem(k, v) } catch {} },
|
|
12
12
|
del(k) { try { localStorage.removeItem(k) } catch {} }
|
|
13
13
|
}
|
|
14
|
+
const CLIENT_ID = (() => {
|
|
15
|
+
try {
|
|
16
|
+
let id = sessionStorage.getItem('dshRemoteClientId')
|
|
17
|
+
if (!id) {
|
|
18
|
+
id = (globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`)
|
|
19
|
+
sessionStorage.setItem('dshRemoteClientId', id)
|
|
20
|
+
}
|
|
21
|
+
return id
|
|
22
|
+
} catch { return '' }
|
|
23
|
+
})()
|
|
14
24
|
const CAP = window.Capacitor || null
|
|
15
25
|
|
|
16
26
|
/* ---------------- 皮肤 ---------------- */
|
|
@@ -40,6 +50,7 @@ themeApply()
|
|
|
40
50
|
/* ---------------- 状态 ---------------- */
|
|
41
51
|
const state = {
|
|
42
52
|
token: LS.get('token', ''),
|
|
53
|
+
wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
|
|
43
54
|
server: '',
|
|
44
55
|
servers: [],
|
|
45
56
|
groups: ['默认'],
|
|
@@ -52,12 +63,17 @@ const state = {
|
|
|
52
63
|
sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
|
|
53
64
|
byId: new Map(),
|
|
54
65
|
current: null,
|
|
66
|
+
hostInfo: null,
|
|
55
67
|
history: { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity },
|
|
56
68
|
approvals: [],
|
|
57
69
|
questions: [],
|
|
58
70
|
questionModal: null,
|
|
59
71
|
streamsOk: { mux: false, host: false },
|
|
60
72
|
errCount: 0,
|
|
73
|
+
streamInfo: {
|
|
74
|
+
mux: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
|
|
75
|
+
host: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
|
|
76
|
+
},
|
|
61
77
|
streamMode: 'ws', // 'ws' | 'poll'
|
|
62
78
|
pollSeq: { mux: 0, host: 0 },
|
|
63
79
|
fs: { path: null, initial: null, loaded: false },
|
|
@@ -498,6 +514,27 @@ function hideTip() { const tip = $('ds-tip'); if (tip) tip.classList.add('hidden
|
|
|
498
514
|
|
|
499
515
|
/* ---------------- API ---------------- */
|
|
500
516
|
function apiUrl(path) { return (state.server || '') + path }
|
|
517
|
+
let wsTicketPromise = null
|
|
518
|
+
async function getWsTicket() {
|
|
519
|
+
const now = Date.now()
|
|
520
|
+
if (state.wsTicket.token === state.token && state.wsTicket.server === state.server &&
|
|
521
|
+
state.wsTicket.value && state.wsTicket.expiresAt > now + 15000) return state.wsTicket.value
|
|
522
|
+
if (wsTicketPromise) return wsTicketPromise
|
|
523
|
+
const token = state.token
|
|
524
|
+
const server = state.server
|
|
525
|
+
wsTicketPromise = (async () => {
|
|
526
|
+
const res = await fetch(apiUrl('/api/ws-ticket'), {
|
|
527
|
+
method: 'POST',
|
|
528
|
+
headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web' }
|
|
529
|
+
})
|
|
530
|
+
if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
|
|
531
|
+
const data = await res.json()
|
|
532
|
+
if (!data?.ticket || !Number(data.expiresAt)) throw new Error('invalid ws ticket')
|
|
533
|
+
state.wsTicket = { token, server, value: data.ticket, expiresAt: Number(data.expiresAt) }
|
|
534
|
+
return data.ticket
|
|
535
|
+
})()
|
|
536
|
+
try { return await wsTicketPromise } finally { wsTicketPromise = null }
|
|
537
|
+
}
|
|
501
538
|
async function rpc(method, payload = {}, timeoutMs = 45000) {
|
|
502
539
|
const opts = {
|
|
503
540
|
method: 'POST',
|
|
@@ -817,12 +854,47 @@ function deleteGroup(name) {
|
|
|
817
854
|
}
|
|
818
855
|
|
|
819
856
|
/* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
|
|
857
|
+
const streamMeta = {
|
|
858
|
+
mux: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
|
|
859
|
+
host: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
|
|
860
|
+
}
|
|
861
|
+
|
|
820
862
|
function clearStreamTimers(ws) {
|
|
821
863
|
if (!ws) return
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
864
|
+
if (ws._retryTimer) clearTimeout(ws._retryTimer)
|
|
865
|
+
ws._retryTimer = null
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function streamIsCurrent(kind, ws, generation) {
|
|
869
|
+
return streams[kind] === ws && streamMeta[kind].generation === generation
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function aggregateStreamFailures() {
|
|
873
|
+
state.errCount = Math.max(streamMeta.mux.failures, streamMeta.host.failures)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function markStreamInfo(kind, patch) {
|
|
877
|
+
state.streamInfo[kind] = { ...state.streamInfo[kind], ...patch }
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function allStreamsOpen() {
|
|
881
|
+
return streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function clearStreamRetry(kind) {
|
|
885
|
+
const meta = streamMeta[kind]
|
|
886
|
+
if (meta.retryTimer) clearTimeout(meta.retryTimer)
|
|
887
|
+
meta.retryTimer = null
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function closeStream(kind) {
|
|
891
|
+
const meta = streamMeta[kind]
|
|
892
|
+
clearStreamRetry(kind)
|
|
893
|
+
meta.generation++
|
|
894
|
+
const ws = streams[kind]
|
|
895
|
+
streams[kind] = null
|
|
896
|
+
state.streamsOk[kind] = false
|
|
897
|
+
try { ws?.close() } catch {}
|
|
826
898
|
}
|
|
827
899
|
|
|
828
900
|
function clearConnTick() {
|
|
@@ -854,78 +926,97 @@ function clearReconnect() {
|
|
|
854
926
|
|
|
855
927
|
function openStreams() {
|
|
856
928
|
if (!state.token) return
|
|
857
|
-
if (state.streamMode
|
|
858
|
-
state.streamMode = 'ws'
|
|
859
|
-
clearReconnect()
|
|
929
|
+
if (state.streamMode !== 'poll') state.streamMode = 'ws'
|
|
860
930
|
openStream('mux', onMuxFrame, true)
|
|
861
931
|
openStream('host', onHostFrame, false)
|
|
862
932
|
}
|
|
863
|
-
function openStream(kind, handler, refreshOnOpen, isRestore) {
|
|
933
|
+
function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
|
|
864
934
|
if (!state.token) return
|
|
935
|
+
if (ticket === null) {
|
|
936
|
+
const token = state.token
|
|
937
|
+
void getWsTicket().then((value) => {
|
|
938
|
+
if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, value)
|
|
939
|
+
}).catch(() => {
|
|
940
|
+
// 兼容旧网关/插件副本: ticket 接口不可用时临时回退旧 token 握手。
|
|
941
|
+
if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, '')
|
|
942
|
+
})
|
|
943
|
+
return
|
|
944
|
+
}
|
|
865
945
|
let base
|
|
866
946
|
if (state.server) base = state.server.replace(/^http/, 'ws')
|
|
867
947
|
else { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; base = `${proto}//${location.host}` }
|
|
868
|
-
const
|
|
869
|
-
|
|
948
|
+
const auth = ticket ? `ticket=${encodeURIComponent(ticket)}` : `token=${encodeURIComponent(state.token)}`
|
|
949
|
+
const clientId = CLIENT_ID ? `&clientId=${encodeURIComponent(CLIENT_ID)}` : ''
|
|
950
|
+
const streamUrl = `${base}/api/events.${kind}?${auth}&client=web${clientId}`
|
|
951
|
+
const current = streams[kind]
|
|
952
|
+
if (current?._streamUrl === streamUrl &&
|
|
953
|
+
(current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) return
|
|
954
|
+
if (current && current._streamUrl !== streamUrl) {
|
|
955
|
+
streamMeta[kind].attempt = 0
|
|
956
|
+
streamMeta[kind].failures = 0
|
|
957
|
+
aggregateStreamFailures()
|
|
958
|
+
}
|
|
959
|
+
closeStream(kind)
|
|
960
|
+
const meta = streamMeta[kind]
|
|
961
|
+
const generation = meta.generation
|
|
962
|
+
const ws = new WebSocket(streamUrl)
|
|
870
963
|
streams[kind] = ws
|
|
871
|
-
ws.
|
|
872
|
-
ws.
|
|
964
|
+
ws._streamUrl = streamUrl
|
|
965
|
+
ws._generation = generation
|
|
873
966
|
ws._isRestore = !!isRestore
|
|
874
967
|
ws.onopen = () => {
|
|
968
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
875
969
|
state.streamsOk[kind] = true
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
970
|
+
markStreamInfo(kind, { status: 'open', lastOpenAt: Date.now(), lastCloseCode: 0, lastCloseReason: '' })
|
|
971
|
+
meta.attempt = 0
|
|
972
|
+
meta.failures = 0
|
|
973
|
+
aggregateStreamFailures()
|
|
879
974
|
clearStreamTimers(ws)
|
|
880
|
-
//
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
try { ws.close() } catch {}
|
|
888
|
-
}
|
|
889
|
-
}, 10000)
|
|
890
|
-
if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
|
|
891
|
-
if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
|
|
975
|
+
// DSH mux/host 是只下行 WebSocket, 浏览器不能发送应用层 ping。
|
|
976
|
+
// 网关负责 RFC6455 Ping/Pong, 前端只监听业务帧和 close 事件。
|
|
977
|
+
if (state.streamMode === 'poll' && allStreamsOpen()) {
|
|
978
|
+
stopPolling()
|
|
979
|
+
state.streamMode = 'ws'
|
|
980
|
+
}
|
|
981
|
+
if (allStreamsOpen()) clearReconnect()
|
|
892
982
|
updateConn()
|
|
893
983
|
if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
|
|
894
984
|
if (refreshOnOpen) refreshSessions()
|
|
895
985
|
}
|
|
896
986
|
ws.onmessage = (msg) => {
|
|
897
|
-
ws
|
|
987
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
898
988
|
state.streamsOk[kind] = true
|
|
899
|
-
state.errCount = 0
|
|
900
989
|
updateConn()
|
|
901
990
|
try { handler(JSON.parse(msg.data)) } catch {}
|
|
902
991
|
}
|
|
903
992
|
ws.onclose = () => {
|
|
904
993
|
clearStreamTimers(ws)
|
|
994
|
+
if (!streamIsCurrent(kind, ws, generation)) return
|
|
995
|
+
streams[kind] = null
|
|
905
996
|
state.streamsOk[kind] = false
|
|
906
|
-
|
|
997
|
+
markStreamInfo(kind, {
|
|
998
|
+
status: 'closed',
|
|
999
|
+
lastCloseAt: Date.now(),
|
|
1000
|
+
lastCloseCode: Number(ws.code) || 0,
|
|
1001
|
+
lastCloseReason: String(ws.reason || ''),
|
|
1002
|
+
})
|
|
1003
|
+
meta.failures++
|
|
1004
|
+
aggregateStreamFailures()
|
|
907
1005
|
updateConn()
|
|
908
1006
|
if (!navigator.onLine) { clearReconnect(); return }
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
916
|
-
setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
|
|
917
|
-
}
|
|
918
|
-
return
|
|
919
|
-
}
|
|
920
|
-
if (state.errCount >= 3) { enterPollMode(); return }
|
|
921
|
-
if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
922
|
-
// 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
|
|
923
|
-
const attempt = ws._attempt || 0
|
|
924
|
-
ws._attempt = attempt + 1
|
|
925
|
-
const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
|
|
1007
|
+
// 任一通道连续失败 3 次就降级轮询;另一个通道不会清零它的失败计数。
|
|
1008
|
+
if (state.streamMode !== 'poll' && meta.failures >= 3) { enterPollMode(); return }
|
|
1009
|
+
if (state.servers.length && meta.failures % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
1010
|
+
// VPN/跨地域链路使用更宽松的指数退避: 1.5s 起步, 最大 60s, 带 20% 抖动。
|
|
1011
|
+
const attempt = meta.attempt++
|
|
1012
|
+
const baseDelay = Math.min(1500 * Math.pow(2, attempt), 60000)
|
|
926
1013
|
const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
|
|
927
1014
|
setReconnect(delay)
|
|
928
|
-
|
|
1015
|
+
clearStreamRetry(kind)
|
|
1016
|
+
meta.retryTimer = setTimeout(() => {
|
|
1017
|
+
meta.retryTimer = null
|
|
1018
|
+
if (state.token && navigator.onLine) openStream(kind, handler, refreshOnOpen, state.streamMode === 'poll')
|
|
1019
|
+
}, delay)
|
|
929
1020
|
}
|
|
930
1021
|
ws.onerror = () => { try { ws.close() } catch {} }
|
|
931
1022
|
}
|
|
@@ -936,10 +1027,8 @@ function enterPollMode() {
|
|
|
936
1027
|
state.streamMode = 'poll'
|
|
937
1028
|
state.pollSeq = { mux: 0, host: 0 }
|
|
938
1029
|
state.streamsOk = { mux: false, host: false }
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
streams.mux = null
|
|
942
|
-
streams.host = null
|
|
1030
|
+
closeStream('mux')
|
|
1031
|
+
closeStream('host')
|
|
943
1032
|
refreshSessions()
|
|
944
1033
|
startPolling()
|
|
945
1034
|
updateConn()
|
|
@@ -1002,15 +1091,15 @@ async function pollKind(kind) {
|
|
|
1002
1091
|
function tryRestoreWs() {
|
|
1003
1092
|
if (state.streamMode !== 'poll' || !state.token) return
|
|
1004
1093
|
// 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
|
|
1005
|
-
openStream('mux', onMuxFrame, true, true)
|
|
1006
|
-
openStream('host', onHostFrame, false, true)
|
|
1094
|
+
if (!streams.mux && !streamMeta.mux.retryTimer) openStream('mux', onMuxFrame, true, true)
|
|
1095
|
+
if (!streams.host && !streamMeta.host.retryTimer) openStream('host', onHostFrame, false, true)
|
|
1007
1096
|
}
|
|
1008
1097
|
|
|
1009
1098
|
/* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
|
|
1010
1099
|
window.addEventListener('offline', () => {
|
|
1011
1100
|
clearReconnect()
|
|
1012
|
-
|
|
1013
|
-
|
|
1101
|
+
closeStream('mux')
|
|
1102
|
+
closeStream('host')
|
|
1014
1103
|
if (state.streamMode === 'poll') stopPolling()
|
|
1015
1104
|
updateConn()
|
|
1016
1105
|
})
|
|
@@ -1055,7 +1144,7 @@ function onHostFrame(full) {
|
|
|
1055
1144
|
if (['host/session-added', 'host/session-removed', 'host/workspace-changed', 'host/workspace-removed', 'host/workspace-order-changed', 'host/archived-sessions-changed'].includes(f.type)) refreshSessions()
|
|
1056
1145
|
if (f.type === 'host/session-status') {
|
|
1057
1146
|
const s = state.byId.get(f.sessionId)
|
|
1058
|
-
if (s) { s.running = f.running; if (state.current === f.sessionId) renderSessions() }
|
|
1147
|
+
if (s) { s.running = f.running; if (state.current === f.sessionId) renderSessions(); renderOverviewDesktop() }
|
|
1059
1148
|
}
|
|
1060
1149
|
}
|
|
1061
1150
|
function applyProjection(sessionId, key, value, seq) {
|
|
@@ -1100,11 +1189,12 @@ function onSessionEvent(sessionId, event) {
|
|
|
1100
1189
|
/* ---------------- 会话 ---------------- */
|
|
1101
1190
|
async function refreshSessions() {
|
|
1102
1191
|
const v = await safeRpc('session.list', {}, '')
|
|
1103
|
-
if (!v) { renderSessions(); return }
|
|
1192
|
+
if (!v) { renderSessions(); renderOverviewDesktop(); return }
|
|
1104
1193
|
state.sessions = v.items || []
|
|
1105
1194
|
state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
|
|
1106
1195
|
renderSessions()
|
|
1107
1196
|
scheduleWorkbenchRefresh()
|
|
1197
|
+
renderOverviewDesktop()
|
|
1108
1198
|
}
|
|
1109
1199
|
function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
|
|
1110
1200
|
function sessionWorkspaceLabel(s) {
|
|
@@ -1135,8 +1225,12 @@ function renderSessions() {
|
|
|
1135
1225
|
const wbIds = new Set()
|
|
1136
1226
|
if (state.wb.bound && state.wb.projects) for (const w of state.wb.projects) for (const id of (w.sessionIds || [])) wbIds.add(id)
|
|
1137
1227
|
const root = state.wb.bound ? state.wb.path : ''
|
|
1138
|
-
const visible = allItems.filter(s => !(state.wb.bound && (wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))))
|
|
1139
1228
|
const archivedSet = new Set(state.archivedIds || [])
|
|
1229
|
+
const visible = allItems.filter(s => {
|
|
1230
|
+
if (!state.wb.bound) return true
|
|
1231
|
+
if (archivedSet.has(s.sessionId)) return true
|
|
1232
|
+
return !(wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))
|
|
1233
|
+
})
|
|
1140
1234
|
const archived = visible.filter(s => archivedSet.has(s.sessionId))
|
|
1141
1235
|
const main = visible.filter(s => !archivedSet.has(s.sessionId))
|
|
1142
1236
|
const showArchived = LS.get('dsShowArchivedV1', '0') === '1'
|
|
@@ -1257,7 +1351,7 @@ function eventHtml(entry) {
|
|
|
1257
1351
|
const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
|
|
1258
1352
|
if (sysText) {
|
|
1259
1353
|
const shown = sysText.length > 400 ? sysText.slice(0, 400) + '…' : sysText
|
|
1260
|
-
return `<details class="event ds-tool"><summary>${esc(t('ds.eventSystemReminder'))}</summary><pre>${esc(shown)}</pre></details>`
|
|
1354
|
+
return `<details class="event ds-tool ds-event-detail"><summary>${esc(t('ds.eventSystemReminder'))}</summary><pre>${esc(shown)}</pre></details>`
|
|
1261
1355
|
}
|
|
1262
1356
|
const text = blocks.map(blockHtml).join('')
|
|
1263
1357
|
return `<div class="ds-msg ${esc(role)}"><div class="role">${esc(role === 'user' ? t('ds.role.me') : t('ds.role.dsh'))}</div>${text || '<span style="opacity:.6">…</span>'}</div>`
|
|
@@ -1457,6 +1551,7 @@ function renderNotifStack() {
|
|
|
1457
1551
|
stack.querySelectorAll('.ds-notif-card').forEach(card => card.addEventListener('keydown', (e) => {
|
|
1458
1552
|
if (e.key === 'Escape') toast(t('ds.ignored'), 'ok')
|
|
1459
1553
|
}))
|
|
1554
|
+
renderOverviewDesktop()
|
|
1460
1555
|
}
|
|
1461
1556
|
async function approveApproval(id, allow) {
|
|
1462
1557
|
const a = state.approvals.find(x => x.approvalId === id)
|
|
@@ -1594,7 +1689,7 @@ async function loadFs(dir, silent) {
|
|
|
1594
1689
|
$('fs-path').textContent = data.path
|
|
1595
1690
|
$('fs-list').innerHTML = (data.entries || []).map(e => `
|
|
1596
1691
|
<div class="ds-fs-row" data-fs-path="${esc(e.path)}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
|
|
1597
|
-
<span>${e.type === 'dir'
|
|
1692
|
+
<span class="ds-fs-type">${desktopFsIconSvg(e.type === 'dir')}</span>
|
|
1598
1693
|
<span class="ds-fs-name">${esc(e.name)}</span>
|
|
1599
1694
|
<span class="ds-fs-size">${e.type === 'dir' ? '' : fmtSize(e.size)}</span>
|
|
1600
1695
|
</div>`).join('') || `<div class="ds-empty">${t('ds.fsEmpty')}</div>`
|
|
@@ -1607,6 +1702,12 @@ async function loadFs(dir, silent) {
|
|
|
1607
1702
|
$('fs-list').innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
|
|
1608
1703
|
}
|
|
1609
1704
|
}
|
|
1705
|
+
|
|
1706
|
+
function desktopFsIconSvg(isDir) {
|
|
1707
|
+
return isDir
|
|
1708
|
+
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 6.5h6l2 2H20a1 1 0 0 1 1 1v8.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5a1 1 0 0 1 .5-1Z"/></svg>'
|
|
1709
|
+
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5h8l4 4V20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1Z"/><path d="M14 3.5v4h4M8 13h8M8 16h6"/></svg>'
|
|
1710
|
+
}
|
|
1610
1711
|
function fsUp() {
|
|
1611
1712
|
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) {
|
|
1612
1713
|
loadFs(fsParent(state.fs.path))
|
|
@@ -1684,15 +1785,21 @@ async function refreshWorkbench({ silent = false } = {}) {
|
|
|
1684
1785
|
const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
|
|
1685
1786
|
if (listRes.ok) {
|
|
1686
1787
|
const listData = await listRes.json().catch(() => ({}))
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
if (
|
|
1695
|
-
|
|
1788
|
+
if (Array.isArray(listData.entries)) {
|
|
1789
|
+
const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
|
|
1790
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
1791
|
+
if (!diskDirs.has(wbPathKey(items[i].path))) items.splice(i, 1)
|
|
1792
|
+
}
|
|
1793
|
+
const have = new Set(items.map(w => wbPathKey(w.path)))
|
|
1794
|
+
for (const entry of listData.entries) {
|
|
1795
|
+
if (entry.type !== 'dir') continue
|
|
1796
|
+
const projectPath = wbJoin(state.wb.path, entry.name)
|
|
1797
|
+
if (have.has(wbPathKey(projectPath))) continue
|
|
1798
|
+
try {
|
|
1799
|
+
const created = await rpc('workspace.create', { path: projectPath })
|
|
1800
|
+
if (created?.workspace) { items.push(created.workspace); have.add(wbPathKey(projectPath)) }
|
|
1801
|
+
} catch {}
|
|
1802
|
+
}
|
|
1696
1803
|
}
|
|
1697
1804
|
}
|
|
1698
1805
|
} catch {}
|
|
@@ -1723,10 +1830,11 @@ function renderWorkbench() {
|
|
|
1723
1830
|
panel.classList.toggle('hidden', !state.wb.expanded)
|
|
1724
1831
|
if (!state.wb.expanded) return
|
|
1725
1832
|
const projects = state.wb.projects || []
|
|
1833
|
+
const archivedSet = new Set(state.archivedIds || [])
|
|
1726
1834
|
let html = `<div class="ds-wb-panel-title">${esc(t('wb.projects'))}</div>`
|
|
1727
1835
|
html += projects.length ? projects.map(w => {
|
|
1728
1836
|
const id = String(w.workspaceId || '')
|
|
1729
|
-
const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
1837
|
+
const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).filter(s => !archivedSet.has(s.sessionId)).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
1730
1838
|
const open = state.wb.open === id
|
|
1731
1839
|
return `<div class="ds-wb-project ${open ? 'open' : ''}">
|
|
1732
1840
|
<button type="button" class="ds-wb-project-head" data-wb-head="${esc(id)}">
|
|
@@ -1777,7 +1885,7 @@ async function wbFsLoad(dir) {
|
|
|
1777
1885
|
const dirs = (data.entries || []).filter(e => e.type === 'dir')
|
|
1778
1886
|
box.innerHTML = dirs.length ? dirs.map(e => {
|
|
1779
1887
|
const p = wbJoin(data.path, e.name)
|
|
1780
|
-
return `<div class="ds-wb-fs-row" data-wb-dir="${esc(p)}"><span
|
|
1888
|
+
return `<div class="ds-wb-fs-row" data-wb-dir="${esc(p)}"><span class="ds-fs-type">${desktopFsIconSvg(true)}</span><span class="ds-wb-fs-name">${esc(e.name)}</span><button type="button" class="ds-btn ds-wb-select" data-wb-select="${esc(p)}">${esc(t('wb.selectDir'))}</button></div>`
|
|
1781
1889
|
}).join('') : `<div class="ds-empty">${esc(t('wb.empty'))}</div>`
|
|
1782
1890
|
box.querySelectorAll('[data-wb-dir]').forEach(row => row.addEventListener('click', e => { if (!e.target.closest('[data-wb-select]')) wbFsLoad(row.dataset.wbDir) }))
|
|
1783
1891
|
box.querySelectorAll('[data-wb-select]').forEach(button => button.addEventListener('click', () => bindWorkbench(button.dataset.wbSelect)))
|
|
@@ -1899,13 +2007,98 @@ function renderStats(days) {
|
|
|
1899
2007
|
}
|
|
1900
2008
|
|
|
1901
2009
|
/* ---------------- 视图与连接状态 ---------------- */
|
|
2010
|
+
function renderOverviewDesktop() {
|
|
2011
|
+
const ring = $('ds-overview-pulse-ring')
|
|
2012
|
+
if (!ring) return
|
|
2013
|
+
const checks = {
|
|
2014
|
+
gateway: !!state.token && !!state.server,
|
|
2015
|
+
dsh: !!state.hostInfo,
|
|
2016
|
+
mux: !!state.streamsOk?.mux,
|
|
2017
|
+
host: !!state.streamsOk?.host
|
|
2018
|
+
}
|
|
2019
|
+
const online = Object.values(checks).filter(Boolean).length
|
|
2020
|
+
const status = online === 4 ? 'Nominal' : online > 0 ? 'Degraded' : 'Offline'
|
|
2021
|
+
const pulseCard = document.querySelector('.ds-overview-pulse-card')
|
|
2022
|
+
if (pulseCard) {
|
|
2023
|
+
pulseCard.classList.remove('status-nominal', 'status-degraded', 'status-offline')
|
|
2024
|
+
pulseCard.classList.add('status-' + status.toLowerCase())
|
|
2025
|
+
}
|
|
2026
|
+
ring.style.setProperty('--pulse-pct', `${online / 4 * 100}%`)
|
|
2027
|
+
$('ds-overview-health').textContent = online === 4 ? t('ds.live') : online ? `${online}/4` : t('ds.offlineCore')
|
|
2028
|
+
$('ds-overview-health-caption').textContent = online === 4 ? t('ds.allLinked') : online ? t('ds.components', { n: online }) : t('ds.offlineShort')
|
|
2029
|
+
$('ds-overview-status').textContent = t(`ds.system${status}`)
|
|
2030
|
+
$('ds-overview-status-desc').textContent = t('ds.components', { n: online })
|
|
2031
|
+
for (const [name, ok] of Object.entries(checks)) {
|
|
2032
|
+
const item = document.querySelector(`[data-ds-overview-link="${name}"]`)
|
|
2033
|
+
if (!item) continue
|
|
2034
|
+
item.classList.toggle('ok', ok)
|
|
2035
|
+
item.classList.toggle('off', !ok)
|
|
2036
|
+
const value = item.querySelector('b')
|
|
2037
|
+
if (value) value.textContent = ok ? t('ds.online') : t('ds.offlineShort')
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
const pending = [
|
|
2041
|
+
...state.approvals.map(a => ({ kind: 'approval', item: a })),
|
|
2042
|
+
...state.questions.map(q => ({ kind: 'question', item: q }))
|
|
2043
|
+
]
|
|
2044
|
+
$('ds-overview-attention-count').textContent = pending.length ? t('ds.pendingCount', { n: pending.length }) : '—'
|
|
2045
|
+
$('ds-overview-attention-list').innerHTML = pending.length ? pending.slice(0, 4).map(({ kind, item }) => {
|
|
2046
|
+
const title = titleOf(state.byId.get(item.sessionId))
|
|
2047
|
+
if (kind === 'approval') return `<div class="ds-overview-attention-item" data-ds-overview-approval="${esc(item.approvalId)}">
|
|
2048
|
+
<span class="ds-overview-mark">⌁</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.toolName || t('ds.toolDefault'))}</span><span class="ds-overview-item-desc">${esc(item.reason || t('ds.approvalReason', { reason: '' }))} · ${esc(title)}</span></span>
|
|
2049
|
+
<span class="ds-overview-actions"><button class="ds-btn allow" data-ds-overview-approve="1">${t('ds.allow')}</button><button class="ds-btn reject" data-ds-overview-approve="0">${t('ds.reject')}</button></span>
|
|
2050
|
+
</div>`
|
|
2051
|
+
return `<button type="button" class="ds-overview-attention-item question" data-ds-overview-question="${esc(item.rpcId)}">
|
|
2052
|
+
<span class="ds-overview-mark">?</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.questions?.[0]?.question || t('ds.questionNotify'))}</span><span class="ds-overview-item-desc">${esc(title)}</span></span><span class="ds-overview-arrow">›</span>
|
|
2053
|
+
</button>`
|
|
2054
|
+
}).join('') : `<div class="ds-overview-empty">${t('ds.nothingPending')}</div>`
|
|
2055
|
+
$('ds-overview-attention-list').querySelectorAll('[data-ds-overview-approve]').forEach(btn => btn.addEventListener('click', () => approveApproval(btn.closest('[data-ds-overview-approval]')?.dataset.dsOverviewApproval || '', btn.dataset.dsOverviewApprove === '1')))
|
|
2056
|
+
$('ds-overview-attention-list').querySelectorAll('[data-ds-overview-question]').forEach(btn => btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.dsOverviewQuestion))))
|
|
2057
|
+
|
|
2058
|
+
const running = state.sessions.filter(s => s.running).length
|
|
2059
|
+
const sessions = [...state.sessions].sort((a, b) => Number(b.running) - Number(a.running) || (new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))).slice(0, 6)
|
|
2060
|
+
const primary = $('ds-overview-primary-action')
|
|
2061
|
+
if (primary) {
|
|
2062
|
+
let action = 'new'
|
|
2063
|
+
let label = t('ds.action.newSession')
|
|
2064
|
+
let sessionId = ''
|
|
2065
|
+
if (!state.token) {
|
|
2066
|
+
action = 'settings'
|
|
2067
|
+
label = t('ds.action.connect')
|
|
2068
|
+
} else if (online > 0 && online < 4) {
|
|
2069
|
+
action = 'refresh'
|
|
2070
|
+
label = t('ds.action.refresh')
|
|
2071
|
+
} else if (pending.length) {
|
|
2072
|
+
action = 'attention'
|
|
2073
|
+
label = t('ds.action.attention')
|
|
2074
|
+
} else if (sessions.length) {
|
|
2075
|
+
action = 'session'
|
|
2076
|
+
sessionId = sessions[0].sessionId
|
|
2077
|
+
label = t('ds.action.openSession')
|
|
2078
|
+
}
|
|
2079
|
+
primary.textContent = label
|
|
2080
|
+
primary.dataset.dsOverviewAction = action
|
|
2081
|
+
primary.dataset.dsOverviewSession = sessionId
|
|
2082
|
+
}
|
|
2083
|
+
$('ds-overview-dsh-version').textContent = state.hostInfo?.version || '—'
|
|
2084
|
+
$('ds-overview-gateway-version').textContent = checks.gateway ? t('ds.online') : t('ds.offlineShort')
|
|
2085
|
+
$('ds-overview-active-sessions').textContent = String(running)
|
|
2086
|
+
$('ds-overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'ds.poll' : 'ds.liveWs') : '—'
|
|
2087
|
+
$('ds-overview-active-count').textContent = running ? t('ds.activeCount', { n: running }) : ''
|
|
2088
|
+
$('ds-overview-session-list').innerHTML = sessions.length ? sessions.map(s => `<button type="button" class="ds-overview-session-item ${s.running ? 'running' : ''}" data-ds-overview-session="${esc(s.sessionId)}">
|
|
2089
|
+
<span class="ds-overview-mark">${s.running ? '●' : '○'}</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(titleOf(s))}</span><span class="ds-overview-item-desc">${s.running ? esc(t('ds.running')) + ' · ' : ''}${esc(fmtTime(s.updatedAt))}</span></span><span class="ds-overview-arrow">›</span>
|
|
2090
|
+
</button>`).join('') : `<div class="ds-overview-empty">${t('ds.noSessions')}</div>`
|
|
2091
|
+
$('ds-overview-session-list').querySelectorAll('[data-ds-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.dsOverviewSession)))
|
|
2092
|
+
}
|
|
2093
|
+
|
|
1902
2094
|
function showView(id) {
|
|
1903
2095
|
state.view = id
|
|
1904
|
-
for (const v of ['view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
2096
|
+
for (const v of ['view-overview', 'view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
1905
2097
|
document.querySelectorAll('.ds-nav-item').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
1906
|
-
const titles = { 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
|
|
2098
|
+
const titles = { 'view-overview': 'ds.overview', 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
|
|
1907
2099
|
if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
|
|
1908
2100
|
else $('ds-title').textContent = t(titles[id])
|
|
2101
|
+
if (id === 'view-overview') renderOverviewDesktop()
|
|
1909
2102
|
if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
|
|
1910
2103
|
if (id === 'view-settings') showSettingsHome()
|
|
1911
2104
|
}
|
|
@@ -1991,6 +2184,28 @@ function bindUi() {
|
|
|
1991
2184
|
list.style.display = list.style.display === 'none' ? 'flex' : 'none'
|
|
1992
2185
|
})
|
|
1993
2186
|
document.querySelectorAll('.ds-nav-item').forEach(b => b.addEventListener('click', () => showView(b.dataset.view)))
|
|
2187
|
+
$('ds-overview-refresh').addEventListener('click', async () => {
|
|
2188
|
+
toast(t('ds.loading'))
|
|
2189
|
+
if (state.token) {
|
|
2190
|
+
await refreshSessions()
|
|
2191
|
+
const host = await safeRpc('host.describe', {}, '')
|
|
2192
|
+
if (host) state.hostInfo = host
|
|
2193
|
+
}
|
|
2194
|
+
renderOverviewDesktop()
|
|
2195
|
+
})
|
|
2196
|
+
$('ds-overview-primary-action').addEventListener('click', () => {
|
|
2197
|
+
const button = $('ds-overview-primary-action')
|
|
2198
|
+
const action = button.dataset.dsOverviewAction
|
|
2199
|
+
if (action === 'session' && button.dataset.dsOverviewSession) return openSession(button.dataset.dsOverviewSession)
|
|
2200
|
+
if (action === 'new') return $('btn-new-session').click()
|
|
2201
|
+
if (action === 'settings') return showView('view-settings')
|
|
2202
|
+
if (action === 'refresh') return $('ds-overview-refresh').click()
|
|
2203
|
+
const first = document.querySelector('.ds-overview-attention-item')
|
|
2204
|
+
if (first) {
|
|
2205
|
+
first.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
2206
|
+
if (first.matches('button')) first.focus({ preventScroll: true })
|
|
2207
|
+
}
|
|
2208
|
+
})
|
|
1994
2209
|
$('session-list').addEventListener('click', (e) => {
|
|
1995
2210
|
if (e.target.closest('[data-archived-toggle]')) {
|
|
1996
2211
|
LS.set('dsShowArchivedV1', LS.get('dsShowArchivedV1', '0') === '1' ? '0' : '1')
|
|
@@ -2128,7 +2343,7 @@ function bindUi() {
|
|
|
2128
2343
|
$('btn-lang').addEventListener('click', () => {
|
|
2129
2344
|
I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
|
|
2130
2345
|
$('btn-lang').textContent = I18N.lang === 'zh' ? 'EN' : '中文'
|
|
2131
|
-
renderServers(); renderSessions(); updateConn(); themeApply()
|
|
2346
|
+
renderServers(); renderSessions(); renderNotifStack(); renderOverviewDesktop(); updateConn(); themeApply()
|
|
2132
2347
|
})
|
|
2133
2348
|
$('fs-up').addEventListener('click', fsUp)
|
|
2134
2349
|
$('fs-new-workspace').addEventListener('click', openWorkspaceModal)
|
|
@@ -2140,7 +2355,7 @@ function bindUi() {
|
|
|
2140
2355
|
async function start() {
|
|
2141
2356
|
loadServers()
|
|
2142
2357
|
renderServers()
|
|
2143
|
-
showView('view-
|
|
2358
|
+
showView('view-overview')
|
|
2144
2359
|
const urlToken = new URLSearchParams(location.search).get('token')
|
|
2145
2360
|
if (urlToken) { state.token = urlToken; LS.set('token', urlToken); history.replaceState(null, '', location.pathname) }
|
|
2146
2361
|
if (!state.token) {
|
|
@@ -2155,9 +2370,12 @@ async function start() {
|
|
|
2155
2370
|
if (state.token) {
|
|
2156
2371
|
if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
|
|
2157
2372
|
openStreams()
|
|
2158
|
-
refreshSessions()
|
|
2373
|
+
await refreshSessions()
|
|
2374
|
+
const host = await safeRpc('host.describe', {}, '')
|
|
2375
|
+
if (host) state.hostInfo = host
|
|
2159
2376
|
refreshWorkbench({ silent: true })
|
|
2160
2377
|
}
|
|
2378
|
+
renderOverviewDesktop()
|
|
2161
2379
|
}
|
|
2162
2380
|
|
|
2163
2381
|
start()
|