neo-vtop 1.0.1

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/app.ts ADDED
@@ -0,0 +1,503 @@
1
+ import Canvas from 'drawille'
2
+ import blessed from 'neo-blessed'
3
+ import os from 'os'
4
+ import childProcess from 'child_process'
5
+ import { Command } from 'commander'
6
+ import { glob } from 'glob'
7
+ import path from 'path'
8
+ import { fileURLToPath } from 'url'
9
+ import { createRequire } from 'module'
10
+ import cpuPlugin from './sensors/cpu'
11
+ import memoryPlugin from './sensors/memory'
12
+ import processPlugin from './sensors/process'
13
+
14
+ process.env.TERM = 'xterm-256color'
15
+
16
+ const require = createRequire(import.meta.url)
17
+ const { version: VERSION } = require('./package.json')
18
+
19
+ const __filename = fileURLToPath(import.meta.url)
20
+ const __dirname = path.dirname(__filename)
21
+
22
+ const App = (() => {
23
+ const cli = new Command()
24
+ let themes = ''
25
+ let program = blessed.program()
26
+
27
+ const files = glob.sync(path.join(__dirname, 'themes', '*.json'))
28
+ for (let i = 0; i < files.length; i++) {
29
+ const themeName = files[i]
30
+ .replace(path.join(__dirname, 'themes') + path.sep, '')
31
+ .replace('.json', '')
32
+ themes += `${themeName}|`
33
+ }
34
+ themes = themes.slice(0, -1)
35
+
36
+ cli
37
+ .option('-t, --theme [name]', `set the neo vtop theme [${themes}]`, 'parallax')
38
+ .option('--no-mouse', 'Disables mouse interactivity')
39
+ .option('--quit-after [seconds]', 'Quits neo vtop after interval', '0')
40
+ .option('--update-interval [milliseconds]', 'Interval between updates', '300')
41
+ .version(VERSION)
42
+ .parse(process.argv)
43
+
44
+ interface ChartData {
45
+ chart: any
46
+ height: number
47
+ width: number
48
+ plugin: any
49
+ values: Record<number, number>
50
+ plugins: any
51
+ }
52
+
53
+ interface LoadedTheme {
54
+ title: { fg: string }
55
+ footer: { fg: string }
56
+ [key: string]: any
57
+ }
58
+
59
+ let screen: any
60
+ const charts: Record<string, ChartData> = {}
61
+ let loadedTheme: LoadedTheme
62
+ const intervals: NodeJS.Timeout[] = []
63
+
64
+ let disableTableUpdate = false
65
+ let disableTableUpdateTimeout: NodeJS.Timeout = setTimeout(() => {}, 0)
66
+ let graphScale = 1
67
+ let position = 0
68
+
69
+ const size = {
70
+ pixel: { width: 0, height: 0 },
71
+ character: { width: 0, height: 0 },
72
+ }
73
+
74
+ let graph: any
75
+ let graph2: any
76
+ let processList: any
77
+ let processListSelection: any
78
+ const sensorMap: Record<string, any> = {
79
+ cpu: cpuPlugin,
80
+ memory: memoryPlugin,
81
+ process: processPlugin,
82
+ }
83
+
84
+ const drawHeader = () => {
85
+ const headerText = ` {bold}neo vtop{/bold}{white-fg} for ${os.hostname()} `
86
+ const headerTextNoTags = ` neo vtop for ${os.hostname()} `
87
+
88
+ const header = blessed.text({
89
+ top: 'top',
90
+ left: 'left',
91
+ width: headerTextNoTags.length,
92
+ height: '1',
93
+ fg: loadedTheme.title.fg,
94
+ content: headerText,
95
+ tags: true,
96
+ })
97
+ const date = blessed.text({
98
+ top: 'top',
99
+ right: 0,
100
+ width: 9,
101
+ height: '1',
102
+ align: 'right',
103
+ content: '',
104
+ tags: true,
105
+ })
106
+ const loadAverage = blessed.text({
107
+ top: 'top',
108
+ height: '1',
109
+ align: 'center',
110
+ content: '',
111
+ tags: true,
112
+ left: Math.floor(program.cols / 2 - 28 / 2),
113
+ })
114
+ screen.append(header)
115
+ screen.append(date)
116
+ screen.append(loadAverage)
117
+
118
+ const zeroPad = (input: number) => `0${input}`.slice(-2)
119
+
120
+ const updateTime = () => {
121
+ const time = new Date()
122
+ date.setContent(
123
+ `${zeroPad(time.getHours())}:${zeroPad(time.getMinutes())}:${zeroPad(time.getSeconds())} `,
124
+ )
125
+ screen.render()
126
+ }
127
+
128
+ const updateLoadAverage = () => {
129
+ const avg = os.loadavg()
130
+ loadAverage.setContent(
131
+ `Load Average: ${avg[0].toFixed(2)} ${avg[1].toFixed(2)} ${avg[2].toFixed(2)}`,
132
+ )
133
+ screen.render()
134
+ }
135
+
136
+ updateTime()
137
+ updateLoadAverage()
138
+ setInterval(updateTime, 1000)
139
+ setInterval(updateLoadAverage, 1000)
140
+ }
141
+
142
+ const drawFooter = () => {
143
+ const commands: Record<string, string> = {
144
+ dd: 'Kill process',
145
+ j: 'Down',
146
+ k: 'Up',
147
+ g: 'Jump to top',
148
+ G: 'Jump to bottom',
149
+ c: 'Sort by CPU',
150
+ m: 'Sort by Mem',
151
+ }
152
+ let text = ''
153
+ for (const c in commands) {
154
+ text += ` {white-bg}{black-fg}${c}{/black-fg}{/white-bg} ${commands[c]}`
155
+ }
156
+ text += '{|}https://github.com/nexusocean8/neo-vtop'
157
+ const footerRight = blessed.box({
158
+ width: '100%',
159
+ top: program.rows - 1,
160
+ tags: true,
161
+ fg: loadedTheme.footer.fg,
162
+ })
163
+ footerRight.setContent(text)
164
+ screen.append(footerRight)
165
+ }
166
+
167
+ const stringRepeat = (string: string, num: number): string => {
168
+ if (num < 0) return ''
169
+ return new Array(num + 1).join(string)
170
+ }
171
+
172
+ const drawChart = (chartKey: string) => {
173
+ const chart = charts[chartKey]
174
+ const c = chart.chart
175
+ c.clear()
176
+
177
+ if (!chart.plugin.initialized) return false
178
+
179
+ const dataPointsToKeep = 5000
180
+ charts[chartKey].values[position] = chart.plugin.currentValue
181
+
182
+ const computeValue = (input: number) =>
183
+ chart.height - Math.floor(((chart.height + 1) / 100) * input) - 1
184
+
185
+ if (position > dataPointsToKeep) {
186
+ delete charts[chartKey].values[position - dataPointsToKeep]
187
+ }
188
+
189
+ for (const pos in charts[chartKey].values) {
190
+ const posInt = parseInt(pos, 10)
191
+
192
+ if (graphScale >= 1 || (graphScale < 1 && posInt % (1 / graphScale) === 0)) {
193
+ const p = posInt + (chart.width - Object.keys(charts[chartKey].values).length)
194
+ const x = p * graphScale + (1 - graphScale) * chart.width
195
+
196
+ if (p > 1 && computeValue(charts[chartKey].values[posInt - 1]) > 0) {
197
+ c.set(x, computeValue(charts[chartKey].values[posInt - 1]))
198
+ }
199
+
200
+ for (let y = computeValue(charts[chartKey].values[posInt - 1]); y < chart.height; y++) {
201
+ if (graphScale > 1 && p > 0 && y > 0) {
202
+ const current = computeValue(charts[chartKey].values[posInt - 1])
203
+ const next = computeValue(charts[chartKey].values[posInt])
204
+ const diff = (next - current) / graphScale
205
+
206
+ for (let i = 0; i < graphScale; i++) {
207
+ c.set(x + i, y + diff * i)
208
+ for (let j = y + diff * i; j < chart.height; j++) {
209
+ c.set(x + i, j)
210
+ }
211
+ }
212
+ } else if (graphScale <= 1) {
213
+ c.set(x, y)
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ const textOutput = c.frame().split('\n')
220
+ const percent = ` ${chart.plugin.currentValue}`
221
+ textOutput[0] = `${textOutput[0].slice(0, textOutput[0].length - 4)}{white-fg}${percent.slice(-3)}%{/white-fg}`
222
+
223
+ return textOutput.join('\n')
224
+ }
225
+
226
+ const drawTable = (chartKey: string) => {
227
+ const chart = charts[chartKey]
228
+ const columnLengths: Record<string, number> = {}
229
+ const columns: string[] = chart.plugin.columns.slice(0)
230
+ columns.reverse()
231
+ let removeColumn = false
232
+ const lastItem = columns[columns.length - 1]
233
+
234
+ const minimumWidth = 12
235
+ let padding = 1
236
+ if (chart.width > 50) padding = 2
237
+ if (chart.width > 80) padding = 3
238
+
239
+ do {
240
+ let totalUsed = 0
241
+ let firstLength = 0
242
+ for (const column in columns) {
243
+ const item = columns[column]
244
+ if (item === lastItem) {
245
+ columnLengths[item] = chart.width - totalUsed
246
+ firstLength = columnLengths[item]
247
+ } else {
248
+ columnLengths[item] = item.length + padding
249
+ }
250
+ totalUsed += columnLengths[item]
251
+ }
252
+ if (firstLength < minimumWidth && columns.length > 1) {
253
+ columns.shift()
254
+ removeColumn = true
255
+ } else {
256
+ removeColumn = false
257
+ }
258
+ } while (removeColumn)
259
+
260
+ columns.reverse()
261
+ let titleOutput = '{bold}'
262
+ for (const headerColumn in columns) {
263
+ const colText = ` ${columns[headerColumn]}`
264
+ titleOutput +=
265
+ colText + stringRepeat(' ', columnLengths[columns[headerColumn]] - colText.length)
266
+ }
267
+ titleOutput += '{/bold}\n'
268
+
269
+ const bodyOutput: string[] = []
270
+ for (const row in chart.plugin.currentValue) {
271
+ const currentRow = chart.plugin.currentValue[row]
272
+ let rowText = ''
273
+ for (const bodyColumn in columns) {
274
+ const colText = ` ${currentRow[columns[bodyColumn]]}`
275
+ rowText += (
276
+ colText + stringRepeat(' ', columnLengths[columns[bodyColumn]] - colText.length)
277
+ ).slice(0, columnLengths[columns[bodyColumn]])
278
+ }
279
+ bodyOutput.push(rowText)
280
+ }
281
+ return {
282
+ title: titleOutput,
283
+ body: bodyOutput,
284
+ processWidth: columnLengths[columns[0]],
285
+ }
286
+ }
287
+
288
+ let currentItems: string[] = []
289
+ let processWidth = 0
290
+
291
+ const draw = () => {
292
+ position++
293
+
294
+ graph.setContent(drawChart('0'))
295
+ graph2.setContent(drawChart('1'))
296
+
297
+ if (!disableTableUpdate) {
298
+ const table = drawTable('2')
299
+ processList.setContent(table.title)
300
+
301
+ const existingStats: Record<string, string> = {}
302
+ for (const stat in currentItems) {
303
+ const thisStat = currentItems[stat]
304
+ existingStats[thisStat.slice(0, table.processWidth)] = thisStat
305
+ }
306
+ processWidth = table.processWidth
307
+
308
+ processListSelection.setItems(table.body)
309
+ processListSelection.focus()
310
+ currentItems = table.body
311
+ }
312
+
313
+ screen.render()
314
+ }
315
+
316
+ return {
317
+ init() {
318
+ const theme = (process as any).theme ?? cli.opts().theme
319
+
320
+ if (cli.opts()['quitAfter'] !== '0') {
321
+ setTimeout(() => process.exit(0), parseInt(cli.opts()['quitAfter'], 10) * 1000)
322
+ }
323
+
324
+ try {
325
+ loadedTheme = require(`./themes/${theme}.json`)
326
+ } catch {
327
+ console.log(`The theme '${theme}' does not exist.`)
328
+ process.exit(1)
329
+ }
330
+
331
+ screen = blessed.screen()
332
+
333
+ let lastKey = ''
334
+
335
+ screen.on('keypress', (ch: any, key: any) => {
336
+ if (['up', 'down', 'k', 'j'].includes(key.name)) {
337
+ disableTableUpdate = true
338
+ clearTimeout(disableTableUpdateTimeout)
339
+ disableTableUpdateTimeout = setTimeout(() => {
340
+ disableTableUpdate = false
341
+ }, 1000)
342
+ }
343
+
344
+ if (key.name === 'q' || key.name === 'escape' || (key.name === 'c' && key.ctrl)) {
345
+ return process.exit(0)
346
+ }
347
+
348
+ if (lastKey === 'd' && key.name === 'd') {
349
+ let selectedProcess = processListSelection.getItem(processListSelection.selected).content
350
+ selectedProcess = selectedProcess.slice(0, processWidth).trim()
351
+ childProcess.exec(`killall "${selectedProcess}"`, () => {})
352
+ }
353
+
354
+ if (key.name === 'c' && charts['2'].plugin.sort !== 'cpu') {
355
+ charts['2'].plugin.sort = 'cpu'
356
+ charts['2'].plugin.poll()
357
+ setTimeout(() => processListSelection.select(0), 200)
358
+ }
359
+
360
+ if (key.name === 'm' && charts['2'].plugin.sort !== 'mem') {
361
+ charts['2'].plugin.sort = 'mem'
362
+ charts['2'].plugin.poll()
363
+ setTimeout(() => processListSelection.select(0), 200)
364
+ }
365
+
366
+ if ((key.name === 'left' || key.name === 'h') && graphScale < 8) {
367
+ graphScale *= 2
368
+ } else if ((key.name === 'right' || key.name === 'l') && graphScale > 0.125) {
369
+ graphScale /= 2
370
+ }
371
+
372
+ lastKey = key.name
373
+ })
374
+
375
+ drawHeader()
376
+ drawFooter()
377
+
378
+ graph = blessed.box({
379
+ top: 1,
380
+ left: 'left',
381
+ width: '100%',
382
+ height: '50%',
383
+ content: '',
384
+ fg: loadedTheme.chart.fg,
385
+ tags: true,
386
+ border: loadedTheme.chart.border,
387
+ })
388
+ screen.append(graph)
389
+
390
+ let graph2appended = false
391
+
392
+ const createBottom = () => {
393
+ if (graph2appended) {
394
+ screen.remove(graph2)
395
+ screen.remove(processList)
396
+ }
397
+ graph2appended = true
398
+
399
+ graph2 = blessed.box({
400
+ top: graph.height + 1,
401
+ left: 'left',
402
+ width: '50%',
403
+ height: graph.height - 2,
404
+ content: '',
405
+ fg: loadedTheme.chart.fg,
406
+ tags: true,
407
+ border: loadedTheme.chart.border,
408
+ })
409
+ screen.append(graph2)
410
+
411
+ processList = blessed.box({
412
+ top: graph.height + 1,
413
+ left: '50%',
414
+ width: screen.width - graph2.width,
415
+ height: graph.height - 2,
416
+ keys: true,
417
+ mouse: cli.opts().mouse,
418
+ fg: loadedTheme.table.fg,
419
+ tags: true,
420
+ border: loadedTheme.table.border,
421
+ })
422
+ screen.append(processList)
423
+
424
+ processListSelection = blessed.list({
425
+ height: processList.height - 3,
426
+ top: 1,
427
+ width: processList.width - 2,
428
+ left: 0,
429
+ keys: true,
430
+ vi: true,
431
+ style: loadedTheme.table.items,
432
+ mouse: cli.opts().mouse,
433
+ })
434
+ processList.append(processListSelection)
435
+ processListSelection.focus()
436
+ screen.render()
437
+ }
438
+
439
+ screen.on('resize', createBottom)
440
+ createBottom()
441
+ screen.append(graph)
442
+ screen.append(processList)
443
+ screen.render()
444
+
445
+ const plugins = ['cpu', 'memory', 'process']
446
+
447
+ const setupCharts = () => {
448
+ size.pixel.width = (graph.width - 2) * 2
449
+ size.pixel.height = (graph.height - 2) * 4
450
+
451
+ plugins.forEach((plugin, index) => {
452
+ let width: number
453
+ let height: number
454
+ let currentCanvas: any
455
+
456
+ switch (plugin) {
457
+ case 'cpu':
458
+ width = (graph.width - 3) * 2
459
+ height = (graph.height - 2) * 4
460
+ currentCanvas = new Canvas(width, height)
461
+ break
462
+ case 'memory':
463
+ width = (graph2.width - 3) * 2
464
+ height = (graph2.height - 2) * 4
465
+ currentCanvas = new Canvas(width, height)
466
+ break
467
+ case 'process':
468
+ default:
469
+ width = processList.width - 3
470
+ height = processList.height - 2
471
+ break
472
+ }
473
+
474
+ const key = String(index)
475
+ const values = charts[key]?.values ?? {}
476
+
477
+ charts[key] = {
478
+ chart: currentCanvas,
479
+ values,
480
+ plugin: sensorMap[plugin],
481
+ plugins,
482
+ width,
483
+ height,
484
+ }
485
+ charts[key].plugin.poll()
486
+ })
487
+
488
+ graph.setLabel(` ${charts['0'].plugin.title} `)
489
+ graph2.setLabel(` ${charts['1'].plugin.title} `)
490
+ processList.setLabel(` ${charts['2'].plugin.title} `)
491
+ }
492
+
493
+ setupCharts()
494
+ screen.on('resize', setupCharts)
495
+ intervals.push(setInterval(draw, parseInt(cli.opts()['updateInterval'], 10)))
496
+ intervals.push(setInterval(charts['0'].plugin.poll, charts['0'].plugin.interval))
497
+ intervals.push(setInterval(charts['1'].plugin.poll, charts['1'].plugin.interval))
498
+ intervals.push(setInterval(charts['2'].plugin.poll, charts['2'].plugin.interval))
499
+ },
500
+ }
501
+ })()
502
+
503
+ App.init()
package/bin/vtop.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env tsx
2
+ import '../app'
Binary file
@@ -0,0 +1,18 @@
1
+ import tseslint from '@typescript-eslint/eslint-plugin'
2
+ import tsparser from '@typescript-eslint/parser'
3
+
4
+ export default [
5
+ {
6
+ files: ['**/*.ts'],
7
+ languageOptions: {
8
+ parser: tsparser,
9
+ },
10
+ plugins: {
11
+ '@typescript-eslint': tseslint,
12
+ },
13
+ rules: {
14
+ ...tseslint.configs.recommended.rules,
15
+ '@typescript-eslint/no-explicit-any': 'off',
16
+ },
17
+ },
18
+ ]
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "neo-vtop",
3
+ "version": "1.0.1",
4
+ "description": "Wow such top. So stats. More neo than vtop, very based.",
5
+ "homepage": "https://github.com/nexusocean8/neo-vtop",
6
+ "main": "app.ts",
7
+ "preferGlobal": true,
8
+ "engines": {
9
+ "node": ">= 24"
10
+ },
11
+ "scripts": {
12
+ "start": "tsx app.ts",
13
+ "lint": "eslint .",
14
+ "prepare": "husky"
15
+ },
16
+ "bin": {
17
+ "neo-vtop": "./bin/vtop.js",
18
+ "vtop": "./bin/vtop.js"
19
+ },
20
+ "author": {
21
+ "name": "Vincent",
22
+ "email": "support@nexusocean.io"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git@github.com:nexusocean8/neo-vtop.git"
27
+ },
28
+ "type": "module",
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "commander": "^15.0.0",
32
+ "drawille": "1.1.0",
33
+ "glob": "^13.0.6",
34
+ "neo-blessed": "^0.2.0",
35
+ "os-utils": "0.0.14"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^26.0.1",
39
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
40
+ "@typescript-eslint/parser": "^8.0.0",
41
+ "eslint": "^9.0.0",
42
+ "husky": "^9.1.7",
43
+ "lint-staged": "^17.0.8",
44
+ "prettier": "^3.9.0",
45
+ "tsx": "latest",
46
+ "typescript": "latest"
47
+ },
48
+ "lint-staged": {
49
+ "*.{ts,js}": [
50
+ "eslint --fix",
51
+ "prettier --write"
52
+ ],
53
+ "*.{json,md}": [
54
+ "prettier --write"
55
+ ]
56
+ }
57
+ }
package/sensors/cpu.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * CPU Usage sensor
3
+ *
4
+ * (c) 2014 James Hall
5
+ */
6
+
7
+ import os from 'os-utils'
8
+
9
+ interface CpuPlugin {
10
+ title: string
11
+ type: string
12
+ interval: number
13
+ initialized: boolean
14
+ currentValue: number
15
+ poll: () => void
16
+ }
17
+
18
+ const plugin: CpuPlugin = {
19
+ title: 'CPU Usage',
20
+ type: 'chart',
21
+ interval: 200,
22
+ initialized: false,
23
+ currentValue: 0,
24
+
25
+ poll() {
26
+ ;(os as any).cpuUsage((v: number) => {
27
+ plugin.currentValue = Math.floor(v * 100)
28
+ plugin.initialized = true
29
+ })
30
+ },
31
+ }
32
+
33
+ export default plugin
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Memory Usage sensor
3
+ *
4
+ * (c) 2014 James Hall
5
+ */
6
+ import os from 'os-utils'
7
+ import _os from 'os'
8
+ import child from 'child_process'
9
+
10
+ interface MemoryPlugin {
11
+ title: string
12
+ type: string
13
+ interval: number
14
+ initialized: boolean
15
+ currentValue: number
16
+ isLinux: boolean
17
+ isMac: boolean
18
+ poll: () => void
19
+ }
20
+
21
+ const computeUsage = (used: number, total: number): number => Math.round(100 * (used / total))
22
+
23
+ const plugin: MemoryPlugin = {
24
+ title: 'Memory Usage',
25
+ type: 'chart',
26
+ interval: 200,
27
+ initialized: false,
28
+ currentValue: 0,
29
+ isLinux: _os.platform().includes('linux'),
30
+ isMac: _os.platform().includes('darwin'),
31
+
32
+ poll() {
33
+ if (plugin.isLinux) {
34
+ child.exec('free -m', (err, stdout) => {
35
+ if (err) console.error(err)
36
+ const data = stdout
37
+ .split('\n')[1]
38
+ .replace(/[\s\n\r]+/g, ' ')
39
+ .split(' ')
40
+ const used = parseInt(data[2], 10)
41
+ const total = parseInt(data[1], 10)
42
+ plugin.currentValue = computeUsage(used, total)
43
+ })
44
+ } else if (plugin.isMac) {
45
+ child.exec('ps -caxm -orss,comm', (err, stdout) => {
46
+ if (err) throw err
47
+ const sp = stdout.split('\n')
48
+ let total = 0
49
+ for (let i = 0; i < sp.length; i++) {
50
+ const val = parseInt(sp[i].replace(/([a-zA-Z]).*/, ''))
51
+ if (!isNaN(val)) total += val
52
+ }
53
+ const usedmem = total / 1024 ** 2
54
+ const freemem = (os as any).totalmem() - usedmem
55
+ const per = freemem / (os as any).totalmem()
56
+ plugin.currentValue = Math.round((1 - per) * 100)
57
+ })
58
+ } else {
59
+ plugin.currentValue = Math.round((1 - (os as any).freememPercentage()) * 100)
60
+ }
61
+
62
+ plugin.initialized = true
63
+ },
64
+ }
65
+
66
+ export default plugin