@electerm/electerm-react 5.3.26 → 5.5.6

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.
Files changed (134) hide show
  1. package/README.md +246 -12
  2. package/client/common/choose-save-folder.js +21 -2
  3. package/client/common/constants.js +20 -6
  4. package/client/common/create-title.jsx +6 -0
  5. package/client/common/default-setting.js +14 -0
  6. package/client/common/init-setting-item.js +5 -0
  7. package/client/common/platform.js +24 -0
  8. package/client/components/ai/agent-tool-call-card.jsx +10 -2
  9. package/client/components/ai/agent.js +4 -1
  10. package/client/components/ai/ai-attachments.js +87 -0
  11. package/client/components/ai/ai-chat-empty.jsx +43 -0
  12. package/client/components/ai/ai-chat-entry.jsx +13 -4
  13. package/client/components/ai/ai-chat-history-item.jsx +28 -4
  14. package/client/components/ai/ai-chat-history.jsx +2 -1
  15. package/client/components/ai/ai-chat.jsx +104 -13
  16. package/client/components/ai/ai-config-props.js +4 -130
  17. package/client/components/ai/ai-config.jsx +135 -15
  18. package/client/components/ai/ai-presets.js +173 -0
  19. package/client/components/ai/ai.styl +83 -0
  20. package/client/components/bg/css-overwrite.jsx +11 -0
  21. package/client/components/bookmark-form/bookmark-schema.js +4 -0
  22. package/client/components/bookmark-form/common/fields.jsx +3 -0
  23. package/client/components/bookmark-form/config/common-fields.js +12 -0
  24. package/client/components/bookmark-form/config/local.js +3 -2
  25. package/client/components/bookmark-form/config/serial.js +3 -2
  26. package/client/components/bookmark-form/config/ssh.js +2 -1
  27. package/client/components/bookmark-form/config/telnet.js +3 -2
  28. package/client/components/common/item-filter.jsx +76 -0
  29. package/client/components/common/item-filter.styl +54 -0
  30. package/client/components/common/lazy-boundary.jsx +34 -0
  31. package/client/components/common/opacity.jsx +3 -1
  32. package/client/components/footer/footer-entry.jsx +35 -5
  33. package/client/components/footer/footer.styl +8 -1
  34. package/client/components/layout/layout.jsx +23 -31
  35. package/client/components/layout/pixed.js +3 -0
  36. package/client/components/main/main.jsx +55 -28
  37. package/client/components/main/term-fullscreen.styl +9 -3
  38. package/client/components/quick-commands/qm.styl +3 -2
  39. package/client/components/rdp/file-transfer.js +4 -4
  40. package/client/components/remote-monitor/monitor-details.jsx +458 -0
  41. package/client/components/remote-monitor/monitor-details.styl +87 -0
  42. package/client/components/remote-monitor/monitor-model.js +764 -0
  43. package/client/components/remote-monitor/remote-monitor-bar-entry.jsx +18 -0
  44. package/client/components/remote-monitor/remote-monitor-bar.jsx +344 -0
  45. package/client/components/remote-monitor/remote-monitor-bar.styl +106 -0
  46. package/client/components/remote-monitor/session-monitor.js +361 -0
  47. package/client/components/remote-monitor/use-monitor-details.js +10 -0
  48. package/client/components/remote-monitor/visibility.js +18 -0
  49. package/client/components/session/session-control.jsx +29 -2
  50. package/client/components/session/session.jsx +40 -18
  51. package/client/components/session/sessions.jsx +5 -2
  52. package/client/components/setting-panel/setting-common.jsx +25 -56
  53. package/client/components/setting-panel/setting-modal.jsx +57 -42
  54. package/client/components/setting-panel/setting-terminal.jsx +7 -2
  55. package/client/components/setting-panel/setting.styl +1 -1
  56. package/client/components/setting-panel/tab-triggers.jsx +71 -0
  57. package/client/components/sftp/file-compare-modal.jsx +6 -3
  58. package/client/components/sftp/file-item.jsx +5 -3
  59. package/client/components/sftp/owner-list.js +11 -2
  60. package/client/components/side-panel-r/side-panel-r.jsx +2 -2
  61. package/client/components/sys-menu/menu-btn.jsx +30 -1
  62. package/client/components/tabs/index.jsx +2 -2
  63. package/client/components/tabs/tab-title.jsx +3 -2
  64. package/client/components/tabs/tab.jsx +5 -1
  65. package/client/components/terminal/attach-addon-custom.js +295 -49
  66. package/client/components/terminal/automation/index.js +64 -0
  67. package/client/components/terminal/automation/keys.js +49 -0
  68. package/client/components/terminal/automation/stream-matcher.js +82 -0
  69. package/client/components/terminal/automation/strip-ansi.js +19 -0
  70. package/client/components/terminal/automation/trigger-engine.js +233 -0
  71. package/client/components/terminal/automation/trigger-presets.js +110 -0
  72. package/client/components/terminal/command-tracker-addon.js +31 -1
  73. package/client/components/terminal/mixins/index.js +27 -0
  74. package/client/components/terminal/mixins/term-attach.js +123 -0
  75. package/client/components/terminal/mixins/term-config.jsx +84 -0
  76. package/client/components/terminal/mixins/term-content.js +88 -0
  77. package/client/components/terminal/mixins/term-context-menu.jsx +291 -0
  78. package/client/components/terminal/mixins/term-file-drop.js +149 -0
  79. package/client/components/terminal/mixins/term-init.js +122 -0
  80. package/client/components/terminal/mixins/term-log.jsx +96 -0
  81. package/client/components/terminal/mixins/term-resize.js +87 -0
  82. package/client/components/terminal/mixins/term-socket.js +433 -0
  83. package/client/components/terminal/mixins/term-suggestions.js +224 -0
  84. package/client/components/terminal/mixins/term-theme.js +190 -0
  85. package/client/components/terminal/mixins/term-touch.js +136 -0
  86. package/client/components/terminal/shell-detect.js +37 -0
  87. package/client/components/terminal/shell.js +0 -28
  88. package/client/components/terminal/shortcut-bar-entry.jsx +6 -3
  89. package/client/components/terminal/shortcut-bar.jsx +4 -6
  90. package/client/components/terminal/shortcut-bar.styl +4 -0
  91. package/client/components/terminal/ssh-reload-state.js +56 -0
  92. package/client/components/terminal/startup-queue.js +379 -0
  93. package/client/components/terminal/terminal-interactive.jsx +32 -0
  94. package/client/components/terminal/terminal.jsx +102 -2007
  95. package/client/components/terminal/transfer-client-base.js +5 -29
  96. package/client/components/terminal/trzsz-client.js +1 -3
  97. package/client/components/terminal/xmodem-client.js +1 -3
  98. package/client/components/terminal/xterm-loader.js +7 -0
  99. package/client/components/terminal/zmodem-client.js +1 -3
  100. package/client/components/terminal-info/base.jsx +18 -70
  101. package/client/components/terminal-info/terminal-info-entry.jsx +13 -5
  102. package/client/components/terminal-info/terminal-info.jsx +50 -59
  103. package/client/components/terminal-info/terminal-info.styl +6 -49
  104. package/client/components/text-editor/open-text-editor.js +30 -0
  105. package/client/components/text-editor/text-editor-entry.jsx +17 -6
  106. package/client/components/text-editor/text-editor.jsx +14 -0
  107. package/client/components/triggers/trigger-editor.jsx +246 -0
  108. package/client/components/triggers/trigger-form.jsx +200 -0
  109. package/client/components/triggers/trigger-session-modal.jsx +199 -0
  110. package/client/components/triggers/trigger-setting-list.jsx +89 -0
  111. package/client/components/triggers/trigger-transport.jsx +60 -0
  112. package/client/components/triggers/triggers-field.jsx +19 -0
  113. package/client/components/widgets/widgets-list.jsx +11 -1
  114. package/client/css/mobile.styl +9 -1
  115. package/client/store/common.js +12 -1
  116. package/client/store/init-state.js +7 -0
  117. package/client/store/mcp-handler.js +158 -30
  118. package/client/store/setting.js +9 -0
  119. package/client/store/store.js +2 -0
  120. package/client/store/sync.js +2 -0
  121. package/client/store/tab.js +41 -3
  122. package/client/store/trigger.js +118 -0
  123. package/client/store/watch.js +13 -5
  124. package/client/views/index.pug +3 -0
  125. package/package.json +1 -1
  126. package/client/components/terminal-info/activity.jsx +0 -57
  127. package/client/components/terminal-info/data-cols-parser.jsx +0 -54
  128. package/client/components/terminal-info/disk.jsx +0 -45
  129. package/client/components/terminal-info/format-disks.js +0 -45
  130. package/client/components/terminal-info/network.jsx +0 -121
  131. package/client/components/terminal-info/resource.jsx +0 -99
  132. package/client/components/terminal-info/run-cmd.jsx +0 -263
  133. package/client/components/terminal-info/sys-info.jsx +0 -56
  134. package/client/components/terminal-info/up.jsx +0 -17
@@ -0,0 +1,764 @@
1
+ /**
2
+ * Pure data, parsing and presentation helpers for the remote monitor bar.
3
+ * This module has no transport or UI dependencies.
4
+ */
5
+
6
+ const ITEM_IDS = [
7
+ 'hostname',
8
+ 'cpu',
9
+ 'cpuHistory',
10
+ 'memory',
11
+ 'upload',
12
+ 'download',
13
+ 'uptime',
14
+ 'users',
15
+ 'disks'
16
+ ]
17
+
18
+ export const REMOTE_MONITOR_ITEM_IDS = Object.freeze([...ITEM_IDS])
19
+
20
+ export const DEFAULT_REMOTE_MONITOR_ITEMS = Object.freeze(
21
+ ITEM_IDS.map(id => Object.freeze({ id, enabled: true }))
22
+ )
23
+
24
+ const KNOWN_ITEM_IDS = new Set(ITEM_IDS)
25
+
26
+ function cloneDefaultItems () {
27
+ return DEFAULT_REMOTE_MONITOR_ITEMS.map(item => ({ ...item }))
28
+ }
29
+
30
+ export function normalizeRemoteMonitorItems (value) {
31
+ if (!Array.isArray(value)) {
32
+ return cloneDefaultItems()
33
+ }
34
+ const result = []
35
+ const seen = new Set()
36
+ for (const entry of value) {
37
+ const id = typeof entry === 'string' ? entry : entry && entry.id
38
+ if (!KNOWN_ITEM_IDS.has(id) || seen.has(id)) {
39
+ continue
40
+ }
41
+ const enabled = entry && typeof entry === 'object' && typeof entry.enabled === 'boolean'
42
+ ? entry.enabled
43
+ : true
44
+ result.push({ id, enabled })
45
+ seen.add(id)
46
+ }
47
+ for (const id of ITEM_IDS) {
48
+ if (!seen.has(id)) {
49
+ result.push({ id, enabled: false })
50
+ }
51
+ }
52
+ return result
53
+ }
54
+
55
+ const NETWORK_COMMAND = [
56
+ '{',
57
+ '[ -d /sys/class/net ] || exit 127;',
58
+ "default_if=$(ip route show default 2>/dev/null | awk 'NR==1 {print $5}');",
59
+ "printf 'default\\t%s\\n' \"$default_if\";",
60
+ 'for d in /sys/class/net/*; do',
61
+ '[ -d "$d" ] || continue;',
62
+ 'n=$' + '{d##*/};',
63
+ '[ "$n" = lo ] && continue;',
64
+ 'state=$(cat "$d/operstate" 2>/dev/null || true);',
65
+ "ipv4=$(ip -o -4 addr show dev \"$n\" 2>/dev/null | awk 'NR==1 {print $4}');",
66
+ 'rx=$(cat "$d/statistics/rx_bytes" 2>/dev/null || true);',
67
+ 'tx=$(cat "$d/statistics/tx_bytes" 2>/dev/null || true);',
68
+ "printf 'iface\\t%s\\tstate=%s\\tipv4=%s\\trx=%s\\ttx=%s\\n' \"$n\" \"$state\" \"$ipv4\" \"$rx\" \"$tx\";",
69
+ 'done;',
70
+ '}'
71
+ ].join(' ')
72
+
73
+ export const REMOTE_MONITOR_COMMANDS = Object.freeze({
74
+ sysinfo: Object.freeze([
75
+ 'uname -s -n -r -m',
76
+ 'grep PRETTY_NAME= /etc/os-release 2>/dev/null || echo'
77
+ ]),
78
+ cpu: "grep '^cpu ' /proc/stat; sleep 0.1; grep '^cpu ' /proc/stat",
79
+ memory: 'cat /proc/meminfo',
80
+ network: NETWORK_COMMAND,
81
+ uptime: 'cat /proc/uptime',
82
+ users: 'who',
83
+ disks: 'df -Pk',
84
+ activities: 'ps -eo pid=,user=,pcpu=,rss=,args= --sort=-pcpu'
85
+ })
86
+
87
+ export const REMOTE_MONITOR_INTERVALS = Object.freeze({
88
+ sysinfo: 0,
89
+ cpu: 5000,
90
+ memory: 5000,
91
+ network: 5000,
92
+ uptime: 5000,
93
+ users: 30000,
94
+ disks: 10000,
95
+ activities: 5000
96
+ })
97
+
98
+ export const REMOTE_MONITOR_COMMAND_DEFINITIONS = Object.freeze(
99
+ Object.fromEntries(Object.keys(REMOTE_MONITOR_COMMANDS).map(name => [
100
+ name,
101
+ Object.freeze({
102
+ command: REMOTE_MONITOR_COMMANDS[name],
103
+ interval: REMOTE_MONITOR_INTERVALS[name]
104
+ })
105
+ ]))
106
+ )
107
+
108
+ function commandResult (input) {
109
+ if (typeof input === 'string') {
110
+ return { text: input, ok: true }
111
+ }
112
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
113
+ return { text: '', ok: false }
114
+ }
115
+ const text = typeof input.stdout === 'string'
116
+ ? input.stdout
117
+ : typeof input.output === 'string'
118
+ ? input.output
119
+ : typeof input.text === 'string'
120
+ ? input.text
121
+ : ''
122
+ const hasFailure = input.timedOut === true || Boolean(input.error) || (
123
+ input.exitCode !== undefined && input.exitCode !== null && Number(input.exitCode) !== 0
124
+ )
125
+ const hasText = typeof input.stdout === 'string' || typeof input.output === 'string' || typeof input.text === 'string'
126
+ return { text, ok: !hasFailure && hasText }
127
+ }
128
+
129
+ function numeric (value) {
130
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
131
+ return null
132
+ }
133
+ const number = typeof value === 'number' ? value : Number(value)
134
+ return Number.isFinite(number) ? number : null
135
+ }
136
+
137
+ function nonNegativeNumber (value) {
138
+ const number = numeric(value)
139
+ return number !== null && number >= 0 ? number : null
140
+ }
141
+
142
+ function parseCounter (value) {
143
+ const number = nonNegativeNumber(value)
144
+ return number !== null && Number.isInteger(number) ? number : null
145
+ }
146
+
147
+ function parseCpuLine (line) {
148
+ const fields = String(line).trim().split(/\s+/)
149
+ if (fields.length < 3 || fields[0] !== 'cpu') {
150
+ return null
151
+ }
152
+ const values = fields.slice(1, 9).map(parseCounter)
153
+ return values.every(value => value !== null) ? values : null
154
+ }
155
+
156
+ function usageFromCpuSamples (first, second) {
157
+ if (!first || !second || first.length < 4 || second.length !== first.length) {
158
+ return null
159
+ }
160
+ const deltas = second.map((value, index) => value - first[index])
161
+ if (deltas.some(value => value < 0)) {
162
+ return null
163
+ }
164
+ const totalDelta = deltas.reduce((sum, value) => sum + value, 0)
165
+ if (totalDelta <= 0) {
166
+ return null
167
+ }
168
+ const idleDelta = (deltas[3] || 0) + (deltas[4] || 0)
169
+ const usedDelta = Math.max(0, totalDelta - idleDelta)
170
+ return Math.max(0, Math.min(100, usedDelta * 100 / totalDelta))
171
+ }
172
+
173
+ export function parseCpu (input) {
174
+ const result = commandResult(input)
175
+ if (!result.ok) {
176
+ return null
177
+ }
178
+ const lines = result.text.split(/\r?\n/).filter(line => /^\s*cpu(?:\s|$)/.test(line))
179
+ const samples = lines.map(parseCpuLine).filter(Boolean)
180
+ if (samples.length >= 2) {
181
+ return usageFromCpuSamples(samples[0], samples[1])
182
+ }
183
+ const legacy = result.text.match(/\bCPU\s+([0-9]+(?:\.[0-9]+)?)\s*%?/i)
184
+ if (!legacy) {
185
+ return null
186
+ }
187
+ const value = numeric(legacy[1])
188
+ return value === null ? null : Math.max(0, Math.min(100, value))
189
+ }
190
+
191
+ const MEMORY_UNITS = {
192
+ b: 1,
193
+ kb: 1024,
194
+ kib: 1024,
195
+ mb: 1024 ** 2,
196
+ mib: 1024 ** 2,
197
+ gb: 1024 ** 3,
198
+ gib: 1024 ** 3,
199
+ tb: 1024 ** 4,
200
+ tib: 1024 ** 4
201
+ }
202
+
203
+ function parseMemoryValue (value, unit) {
204
+ const number = numeric(value)
205
+ if (number === null || number < 0) {
206
+ return null
207
+ }
208
+ const multiplier = unit ? MEMORY_UNITS[String(unit).toLowerCase()] : 1
209
+ return multiplier ? number * multiplier : null
210
+ }
211
+
212
+ export function parseMemory (input) {
213
+ const result = commandResult(input)
214
+ if (!result.ok || !result.text.trim()) {
215
+ return null
216
+ }
217
+ const values = {}
218
+ for (const line of result.text.split(/\r?\n/)) {
219
+ const match = line.match(/^([^:]+):\s*([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)?\s*$/)
220
+ if (!match) {
221
+ continue
222
+ }
223
+ const value = parseMemoryValue(match[2], match[3])
224
+ if (value !== null) {
225
+ values[match[1]] = value
226
+ }
227
+ }
228
+ const totalBytes = values.MemTotal
229
+ if (totalBytes === undefined || totalBytes <= 0) {
230
+ return null
231
+ }
232
+ const freeBytes = values.MemFree === undefined ? null : values.MemFree
233
+ const hasAvailable = values.MemAvailable !== undefined
234
+ const rawAvailable = hasAvailable ? values.MemAvailable : freeBytes
235
+ if (rawAvailable === null || rawAvailable === undefined) {
236
+ return null
237
+ }
238
+ const availableBytes = Math.max(0, Math.min(totalBytes, rawAvailable))
239
+ const usedBytes = totalBytes - availableBytes
240
+ const swapTotalBytes = values.SwapTotal === undefined ? null : values.SwapTotal
241
+ const swapFreeBytes = values.SwapFree === undefined ? null : values.SwapFree
242
+ const swapUsedBytes = swapTotalBytes === null || swapFreeBytes === null
243
+ ? null
244
+ : Math.max(0, swapTotalBytes - Math.min(swapTotalBytes, swapFreeBytes))
245
+ return {
246
+ totalBytes,
247
+ availableBytes,
248
+ usedBytes,
249
+ freeBytes,
250
+ swapTotalBytes,
251
+ swapUsedBytes,
252
+ percent: usedBytes * 100 / totalBytes,
253
+ compatibilityMode: !hasAvailable
254
+ }
255
+ }
256
+
257
+ export function parseUptime (input, sampleTimestamp) {
258
+ const result = commandResult(input)
259
+ if (!result.ok || !result.text.trim()) {
260
+ return null
261
+ }
262
+ const match = result.text.trim().match(/^([0-9]+(?:\.[0-9]+)?)(?:\s|$)/)
263
+ if (!match) {
264
+ return null
265
+ }
266
+ const seconds = nonNegativeNumber(match[1])
267
+ if (seconds === null) {
268
+ return null
269
+ }
270
+ const timestamp = numeric(sampleTimestamp !== undefined ? sampleTimestamp : input && input.sampleTimestamp)
271
+ return {
272
+ seconds,
273
+ bootTime: timestamp === null ? null : timestamp - seconds * 1000
274
+ }
275
+ }
276
+
277
+ export function parseSysInfo (unameInput, prettyInput) {
278
+ const uname = commandResult(unameInput)
279
+ if (!uname.ok || !uname.text.trim()) {
280
+ return null
281
+ }
282
+ const fields = uname.text.trim().split(/\s+/)
283
+ if (fields.length < 4) {
284
+ return null
285
+ }
286
+ const [sysname, hostname, kernel, arch] = fields
287
+ const pretty = prettyInput === undefined ? { text: '', ok: true } : commandResult(prettyInput)
288
+ const prettyMatch = pretty.text.match(/(?:^|\n)PRETTY_NAME=([^\r\n]*)/)
289
+ let os = prettyMatch ? prettyMatch[1].trim() : sysname
290
+ if (os.startsWith('"') && os.endsWith('"')) {
291
+ os = os.slice(1, -1)
292
+ }
293
+ if (sysname === 'Darwin' && os === sysname) {
294
+ os = 'macOS'
295
+ }
296
+ return { os, sysname, hostname, kernel, arch }
297
+ }
298
+
299
+ function isLoopback (name) {
300
+ return name === 'lo' || name.startsWith('lo:')
301
+ }
302
+
303
+ function parseInterfaceCounter (value) {
304
+ if (value === undefined || value === '') {
305
+ return null
306
+ }
307
+ return parseCounter(value)
308
+ }
309
+
310
+ function parseNetworkLine (line) {
311
+ const trimmed = line.trim()
312
+ if (!trimmed) {
313
+ return null
314
+ }
315
+ const fields = trimmed.split(/\s+/)
316
+ if (fields[0].toLowerCase() === 'iface' && fields.length >= 2) {
317
+ const name = fields[1]
318
+ const attributes = fields.slice(2)
319
+ const stateField = attributes.find(field => /^state=/i.test(field))
320
+ const ipv4Field = attributes.find(field => /^ipv4=/i.test(field))
321
+ const rxField = attributes.find(field => /^rx(?:_bytes)?=/i.test(field))
322
+ const txField = attributes.find(field => /^tx(?:_bytes)?=/i.test(field))
323
+ return {
324
+ name,
325
+ ipv4: ipv4Field ? (ipv4Field.slice(ipv4Field.indexOf('=') + 1).split('/')[0] || null) : null,
326
+ state: stateField ? stateField.slice(stateField.indexOf('=') + 1) || null : null,
327
+ rxBytes: rxField ? parseInterfaceCounter(rxField.slice(rxField.indexOf('=') + 1)) : null,
328
+ txBytes: txField ? parseInterfaceCounter(txField.slice(txField.indexOf('=') + 1)) : null
329
+ }
330
+ }
331
+ if (fields.length < 2 || /^(default|route)$/i.test(fields[0])) {
332
+ return null
333
+ }
334
+ const name = fields[0]
335
+ const numericFields = fields.slice(1).filter(field => /^\d+$/.test(field))
336
+ if (numericFields.length < 2) {
337
+ return null
338
+ }
339
+ const state = fields.slice(1).find(field => /^(up|down|unknown|dormant|lowerlayerdown)$/i.test(field)) || null
340
+ const ipv4 = fields.slice(1).find(field => /^\d{1,3}(?:\.\d{1,3}){3}(?:\/\d{1,2})?$/.test(field))
341
+ return {
342
+ name,
343
+ ipv4: ipv4 ? ipv4.split('/')[0] : null,
344
+ state,
345
+ rxBytes: parseInterfaceCounter(numericFields[0]),
346
+ txBytes: parseInterfaceCounter(numericFields[1])
347
+ }
348
+ }
349
+
350
+ export function parseNetwork (input) {
351
+ const result = commandResult(input)
352
+ if (!result.ok || !result.text.trim()) {
353
+ return null
354
+ }
355
+ let defaultInterface = null
356
+ const interfaces = []
357
+ const seen = new Set()
358
+ for (const line of result.text.split(/\r?\n/)) {
359
+ const trimmed = line.trim()
360
+ if (!trimmed) {
361
+ continue
362
+ }
363
+ const defaultMatch = trimmed.match(/^(?:default|route)(?:\s+|=)([^\s]+)/i)
364
+ if (defaultMatch) {
365
+ defaultInterface = defaultMatch[1]
366
+ continue
367
+ }
368
+ const parsed = parseNetworkLine(line)
369
+ if (!parsed || !parsed.name || seen.has(parsed.name)) {
370
+ continue
371
+ }
372
+ interfaces.push(parsed)
373
+ seen.add(parsed.name)
374
+ }
375
+ return interfaces.length || defaultInterface ? { defaultInterface, interfaces } : null
376
+ }
377
+
378
+ function interfaceByName (network, name) {
379
+ return network && Array.isArray(network.interfaces)
380
+ ? network.interfaces.find(item => item.name === name) || null
381
+ : null
382
+ }
383
+
384
+ export function selectPrimaryNetwork (network) {
385
+ if (!network || !Array.isArray(network.interfaces)) {
386
+ return null
387
+ }
388
+ const preferred = interfaceByName(network, network.defaultInterface)
389
+ if (preferred && !isLoopback(preferred.name)) {
390
+ return preferred
391
+ }
392
+ return network.interfaces
393
+ .filter(item => item && item.name && !isLoopback(item.name))
394
+ .slice()
395
+ .sort((a, b) => {
396
+ const aActive = String(a.state || '').toLowerCase() === 'up' ? 1 : 0
397
+ const bActive = String(b.state || '').toLowerCase() === 'up' ? 1 : 0
398
+ const aComplete = Number.isFinite(a.rxBytes) && Number.isFinite(a.txBytes) ? 1 : 0
399
+ const bComplete = Number.isFinite(b.rxBytes) && Number.isFinite(b.txBytes) ? 1 : 0
400
+ return bActive - aActive || bComplete - aComplete || a.name.localeCompare(b.name)
401
+ })[0] || null
402
+ }
403
+
404
+ function networkTimestamp (network, fallback) {
405
+ const value = fallback !== undefined
406
+ ? fallback
407
+ : network && (network.sampleTimestamp !== undefined ? network.sampleTimestamp : network.timestampMs)
408
+ return numeric(value)
409
+ }
410
+
411
+ function cloneNetworkWithRates (network, sampleTimestamp, rates, reset, resetReason, elapsedMs) {
412
+ return {
413
+ defaultInterface: network.defaultInterface || null,
414
+ interfaces: network.interfaces.map(item => ({
415
+ ...item,
416
+ rxRate: rates[item.name] ? rates[item.name].rxRate : null,
417
+ txRate: rates[item.name] ? rates[item.name].txRate : null
418
+ })),
419
+ sampleTimestamp,
420
+ elapsedMs: elapsedMs === undefined ? null : elapsedMs,
421
+ reset: Boolean(reset),
422
+ resetReason: resetReason || null
423
+ }
424
+ }
425
+
426
+ export function deriveNetworkRates (previous, current, sampleTimestamp) {
427
+ if (!current || !Array.isArray(current.interfaces)) {
428
+ return null
429
+ }
430
+ const timestamp = networkTimestamp(current, sampleTimestamp)
431
+ const rates = {}
432
+ const resetAll = reason => cloneNetworkWithRates(current, timestamp, rates, true, reason)
433
+ if (!previous || !Array.isArray(previous.interfaces)) {
434
+ return resetAll('initial-sample')
435
+ }
436
+ const previousTimestamp = networkTimestamp(previous)
437
+ if (timestamp === null || previousTimestamp === null || timestamp <= previousTimestamp) {
438
+ return resetAll('invalid-elapsed-time')
439
+ }
440
+ const previousPrimary = selectPrimaryNetwork(previous)
441
+ const currentPrimary = selectPrimaryNetwork(current)
442
+ if ((previousPrimary && currentPrimary && previousPrimary.name !== currentPrimary.name) ||
443
+ (!previousPrimary && currentPrimary) || (previousPrimary && !currentPrimary)) {
444
+ return resetAll('interface-changed')
445
+ }
446
+ const elapsedMs = timestamp - previousTimestamp
447
+ let hadRollback = false
448
+ for (const item of current.interfaces) {
449
+ const old = interfaceByName(previous, item.name)
450
+ if (!old || item.rxBytes === null || item.txBytes === null || old.rxBytes === null || old.txBytes === null) {
451
+ continue
452
+ }
453
+ const rxDelta = item.rxBytes - old.rxBytes
454
+ const txDelta = item.txBytes - old.txBytes
455
+ if (rxDelta < 0 || txDelta < 0) {
456
+ hadRollback = true
457
+ continue
458
+ }
459
+ rates[item.name] = {
460
+ rxRate: rxDelta * 1000 / elapsedMs,
461
+ txRate: txDelta * 1000 / elapsedMs
462
+ }
463
+ }
464
+ return cloneNetworkWithRates(current, timestamp, rates, hadRollback, hadRollback ? 'counter-rollback' : null, elapsedMs)
465
+ }
466
+
467
+ export function parseUsers (input) {
468
+ const result = commandResult(input)
469
+ if (!result.ok) {
470
+ return null
471
+ }
472
+ const sessions = []
473
+ for (const line of result.text.split(/\r?\n/)) {
474
+ const trimmed = line.trim()
475
+ if (!trimmed) {
476
+ continue
477
+ }
478
+ const fields = trimmed.split(/\s+/)
479
+ if (fields.length < 2) {
480
+ continue
481
+ }
482
+ const sourceMatch = trimmed.match(/\(([^)]*)\)\s*$/)
483
+ const source = sourceMatch ? sourceMatch[1] : null
484
+ const withoutSource = sourceMatch ? trimmed.slice(0, sourceMatch.index).trim() : trimmed
485
+ const parts = withoutSource.split(/\s+/)
486
+ const user = parts[0]
487
+ const terminal = parts[1] || null
488
+ const loginTime = parts.slice(2).join(' ') || null
489
+ sessions.push({ user, terminal, loginTime, source })
490
+ }
491
+ if (result.text.trim() && !sessions.length) {
492
+ return null
493
+ }
494
+ const users = []
495
+ const seen = new Set()
496
+ for (const session of sessions) {
497
+ if (!seen.has(session.user)) {
498
+ users.push(session.user)
499
+ seen.add(session.user)
500
+ }
501
+ }
502
+ return { users, sessions }
503
+ }
504
+
505
+ const PSEUDO_FILESYSTEMS = new Set([
506
+ 'autofs', 'bpf', 'cgroup', 'cgroup2', 'configfs', 'debugfs', 'devpts',
507
+ 'devtmpfs', 'fusectl', 'hugetlbfs', 'mqueue', 'nsfs', 'pstore', 'proc',
508
+ 'ramfs', 'securityfs', 'squashfs', 'sysfs', 'tmpfs'
509
+ ])
510
+
511
+ function decodeMount (value) {
512
+ return value
513
+ .replace(/\\040/g, ' ')
514
+ .replace(/\\011/g, '\t')
515
+ .replace(/\\134/g, '\\')
516
+ }
517
+
518
+ function diskFromFields (filesystem, fields) {
519
+ const percentIndex = fields.findIndex(field => /^\d+%$/.test(field))
520
+ if (percentIndex < 3 || percentIndex === fields.length - 1) {
521
+ return null
522
+ }
523
+ const blocks = numeric(fields[percentIndex - 3])
524
+ const usedBlocks = numeric(fields[percentIndex - 2])
525
+ const availableBlocks = numeric(fields[percentIndex - 1])
526
+ const percent = numeric(fields[percentIndex].slice(0, -1))
527
+ const mount = decodeMount(fields.slice(percentIndex + 1).join(' '))
528
+ if ([blocks, usedBlocks, availableBlocks, percent].some(value => value === null) || !mount) {
529
+ return null
530
+ }
531
+ const filesystemName = filesystem || fields.slice(0, percentIndex - 3).join(' ')
532
+ if (!filesystemName) {
533
+ return null
534
+ }
535
+ return {
536
+ filesystem: filesystemName,
537
+ totalBytes: blocks * 1024,
538
+ usedBytes: usedBlocks * 1024,
539
+ availableBytes: availableBlocks * 1024,
540
+ percent,
541
+ mount
542
+ }
543
+ }
544
+
545
+ function isRelevantDisk (disk) {
546
+ const fs = disk.filesystem.toLowerCase()
547
+ if (fs === 'overlay') {
548
+ return disk.mount === '/'
549
+ }
550
+ return !PSEUDO_FILESYSTEMS.has(fs)
551
+ }
552
+
553
+ export function parseDisks (input) {
554
+ const result = commandResult(input)
555
+ if (!result.ok || !result.text.trim()) {
556
+ return null
557
+ }
558
+ const disks = []
559
+ let wrappedFilesystem = null
560
+ let sawHeader = false
561
+ for (const line of result.text.split(/\r?\n/)) {
562
+ if (!line.trim()) {
563
+ continue
564
+ }
565
+ if (/^\s*Filesystem\s+/i.test(line)) {
566
+ sawHeader = true
567
+ continue
568
+ }
569
+ const fields = line.trim().split(/\s+/)
570
+ const percentIndex = fields.findIndex(field => /^\d+%$/.test(field))
571
+ let disk
572
+ if (/^\s/.test(line) && wrappedFilesystem && percentIndex >= 3) {
573
+ disk = diskFromFields(wrappedFilesystem, fields)
574
+ wrappedFilesystem = null
575
+ } else if (percentIndex >= 3) {
576
+ disk = diskFromFields(null, fields)
577
+ } else if (fields.length === 1) {
578
+ wrappedFilesystem = fields[0]
579
+ }
580
+ if (disk && isRelevantDisk(disk)) {
581
+ disks.push(disk)
582
+ }
583
+ }
584
+ return sawHeader || disks.length ? disks : null
585
+ }
586
+
587
+ export function parseActivities (input) {
588
+ const result = commandResult(input)
589
+ if (!result.ok || !result.text.trim()) {
590
+ return null
591
+ }
592
+ const activities = []
593
+ let sawHeader = false
594
+ for (const line of result.text.split(/\r?\n/)) {
595
+ const trimmed = line.trim()
596
+ if (!trimmed) {
597
+ continue
598
+ }
599
+ if (/^pid\s+/i.test(trimmed)) {
600
+ sawHeader = true
601
+ continue
602
+ }
603
+ const fields = trimmed.split(/\s+/)
604
+ if (fields.length < 4 || !/^\d+$/.test(fields[0])) {
605
+ continue
606
+ }
607
+ const cpu = numeric(fields[2])
608
+ const rssKb = numeric(fields[3])
609
+ if (cpu === null || rssKb === null || rssKb < 0) {
610
+ continue
611
+ }
612
+ activities.push({
613
+ pid: Number(fields[0]),
614
+ user: fields[1],
615
+ cpu,
616
+ memBytes: rssKb * 1024,
617
+ cmd: fields.slice(4).join(' ')
618
+ })
619
+ }
620
+ if (!activities.length && !sawHeader) {
621
+ return null
622
+ }
623
+ return activities.sort((a, b) => b.cpu - a.cpu || a.pid - b.pid)
624
+ }
625
+
626
+ export function getUsageLevel (value, previous = 'normal') {
627
+ const number = numeric(value)
628
+ if (number === null || number < 0) {
629
+ return 'unknown'
630
+ }
631
+ const level = previous === 'critical' || previous === 'warning' || previous === 'normal'
632
+ ? previous
633
+ : 'normal'
634
+ if (level === 'critical') {
635
+ if (number >= 85) return 'critical'
636
+ if (number >= 80) return 'warning'
637
+ return 'normal'
638
+ }
639
+ if (level === 'warning') {
640
+ if (number >= 90) return 'critical'
641
+ if (number >= 75) return 'warning'
642
+ return 'normal'
643
+ }
644
+ if (number >= 90) return 'critical'
645
+ if (number >= 80) return 'warning'
646
+ return 'normal'
647
+ }
648
+
649
+ function compactNumber (value, decimals) {
650
+ const rounded = Number(value.toFixed(decimals))
651
+ return String(rounded)
652
+ }
653
+
654
+ export function formatBytes (value) {
655
+ const bytes = numeric(value)
656
+ if (bytes === null || bytes < 0) {
657
+ return '—'
658
+ }
659
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
660
+ let index = 0
661
+ let scaled = bytes
662
+ while (scaled >= 1024 && index < units.length - 1) {
663
+ scaled /= 1024
664
+ index += 1
665
+ }
666
+ const decimals = index === 0 ? 0 : scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2
667
+ return `${compactNumber(scaled, decimals)} ${units[index]}`
668
+ }
669
+
670
+ export function formatRate (value) {
671
+ const bytes = numeric(value)
672
+ return bytes === null || bytes < 0 ? '—' : `${formatBytes(bytes)}/s`
673
+ }
674
+
675
+ export function formatDuration (value) {
676
+ const seconds = numeric(value)
677
+ if (seconds === null || seconds < 0) {
678
+ return '—'
679
+ }
680
+ let remaining = Math.floor(seconds)
681
+ const days = Math.floor(remaining / 86400)
682
+ remaining %= 86400
683
+ const hours = Math.floor(remaining / 3600)
684
+ remaining %= 3600
685
+ const minutes = Math.floor(remaining / 60)
686
+ const secs = remaining % 60
687
+ if (days > 0) {
688
+ return `${days}d ${String(hours).padStart(2, '0')}h ${String(minutes).padStart(2, '0')}m ${String(secs).padStart(2, '0')}s`
689
+ }
690
+ if (hours > 0) {
691
+ return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(secs).padStart(2, '0')}s`
692
+ }
693
+ if (minutes > 0) {
694
+ return `${minutes}m ${String(secs).padStart(2, '0')}s`
695
+ }
696
+ return `${secs}s`
697
+ }
698
+
699
+ export const MONITOR_ITEM_GROUP = {
700
+ hostname: 'sysinfo',
701
+ cpu: 'cpu',
702
+ cpuHistory: 'cpu',
703
+ memory: 'memory',
704
+ upload: 'network',
705
+ download: 'network',
706
+ uptime: 'uptime',
707
+ users: 'users',
708
+ disks: 'disks',
709
+ swap: 'memory',
710
+ network: 'network',
711
+ activities: 'activities'
712
+ }
713
+
714
+ const DISK_PRIORITY = ['/', '/home', '/var', '/data']
715
+
716
+ // Selectable info panel sections, in the order they are displayed.
717
+ // `mem` is the config id, `memory` is the monitor group id.
718
+ export const INFO_PANEL_ITEM_IDS = Object.freeze([
719
+ 'uptime',
720
+ 'cpu',
721
+ 'mem',
722
+ 'activities',
723
+ 'network',
724
+ 'disks',
725
+ 'users'
726
+ ])
727
+
728
+ const INFO_PANEL_ORDER = [
729
+ 'hostname',
730
+ 'uptime',
731
+ 'cpu',
732
+ 'memory',
733
+ 'swap',
734
+ 'activities',
735
+ 'network',
736
+ 'disks',
737
+ 'users'
738
+ ]
739
+
740
+ export function groupOf (snapshot, name) {
741
+ return snapshot.groups[name] || {
742
+ status: 'idle',
743
+ data: null,
744
+ updatedAt: null,
745
+ error: null
746
+ }
747
+ }
748
+
749
+ export function sortDisks (disks) {
750
+ return [...(Array.isArray(disks) ? disks : [])].sort((a, b) => {
751
+ const ai = DISK_PRIORITY.indexOf(a.mount)
752
+ const bi = DISK_PRIORITY.indexOf(b.mount)
753
+ const ar = ai === -1 ? DISK_PRIORITY.length : ai
754
+ const br = bi === -1 ? DISK_PRIORITY.length : bi
755
+ return ar - br || a.mount.localeCompare(b.mount)
756
+ })
757
+ }
758
+
759
+ export function getInfoPanelItems (items = []) {
760
+ const selected = new Set(['hostname', ...items.map(id => id === 'mem' ? 'memory' : id)])
761
+ if (selected.has('memory')) selected.delete('swap')
762
+ // Always render in the fixed order, no matter how the user toggled them
763
+ return INFO_PANEL_ORDER.filter(id => selected.has(id) && MONITOR_ITEM_GROUP[id])
764
+ }