@logtrace/tracker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +41 -0
- package/src/client.js +181 -0
- package/src/commands/alert.js +34 -0
- package/src/commands/config.js +306 -0
- package/src/commands/docker.js +139 -0
- package/src/commands/doctor.js +205 -0
- package/src/commands/login.js +227 -0
- package/src/commands/logs.js +135 -0
- package/src/commands/status.js +28 -0
- package/src/commands/update.js +598 -0
- package/src/index.js +235 -0
- package/src/interactive.js +384 -0
- package/src/ui/renderer.js +136 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/cli/src/commands/doctor.js
|
|
3
|
+
*
|
|
4
|
+
* tracker doctor — run all system health checks and display results as a table.
|
|
5
|
+
* tracker health — show only problems (warn + critical), or "sistema saludable".
|
|
6
|
+
*
|
|
7
|
+
* Both commands share the same daemon call (doctor.run) and differ only in
|
|
8
|
+
* how they filter and display the results.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import chalk from 'chalk'
|
|
12
|
+
import ora from 'ora'
|
|
13
|
+
import Table from 'cli-table3'
|
|
14
|
+
import { createClient } from '../client.js'
|
|
15
|
+
import { printError } from '../ui/renderer.js'
|
|
16
|
+
|
|
17
|
+
// ── Status icons and colors ───────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const STATUS_ICON = {
|
|
20
|
+
ok: chalk.green('✔'),
|
|
21
|
+
warn: chalk.yellow('!'),
|
|
22
|
+
critical: chalk.red('✖'),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STATUS_LABEL = {
|
|
26
|
+
ok: chalk.green('ok'),
|
|
27
|
+
warn: chalk.yellow('warn'),
|
|
28
|
+
critical: chalk.red('critical'),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function statusIcon(status) {
|
|
32
|
+
return STATUS_ICON[status] ?? chalk.dim('?')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function statusLabel(status) {
|
|
36
|
+
return STATUS_LABEL[status] ?? chalk.dim(status)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Shared fetch ──────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
async function fetchChecks() {
|
|
42
|
+
const client = createClient()
|
|
43
|
+
try {
|
|
44
|
+
await client.connect()
|
|
45
|
+
const result = await client.send('doctor.run', {})
|
|
46
|
+
return { checks: result.checks, client }
|
|
47
|
+
} catch (err) {
|
|
48
|
+
client.close()
|
|
49
|
+
throw err
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── tracker doctor ────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* doctorCommand — full diagnostic table.
|
|
57
|
+
*
|
|
58
|
+
* Displays all 9 checks with their status and message.
|
|
59
|
+
* Shows a summary line at the end: "X/9 checks OK • Y advertencias • Z críticos"
|
|
60
|
+
*
|
|
61
|
+
* Example output:
|
|
62
|
+
* ┌────────────────┬──────────┬───────────────────────────────────────────┬─────────────────────────────┐
|
|
63
|
+
* │ Check │ Estado │ Mensaje │ Solución │
|
|
64
|
+
* ├────────────────┼──────────┼───────────────────────────────────────────┼─────────────────────────────┤
|
|
65
|
+
* │ ✔ disk_space │ ok │ Disco / al 45.2% │ │
|
|
66
|
+
* │ ! swap_enabled │ warn │ Sin swap configurado │ │
|
|
67
|
+
* │ ✖ last_backup │ critical │ Sin backups configurados │ tracker backup create │
|
|
68
|
+
* └────────────────┴──────────┴───────────────────────────────────────────┴─────────────────────────────┘
|
|
69
|
+
* X/9 checks OK • Y advertencias • Z críticos
|
|
70
|
+
*/
|
|
71
|
+
export async function doctorCommand(_argv) {
|
|
72
|
+
const spinner = ora('Ejecutando checks...').start()
|
|
73
|
+
let client = null
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const { checks, client: c } = await fetchChecks()
|
|
77
|
+
client = c
|
|
78
|
+
|
|
79
|
+
spinner.stop()
|
|
80
|
+
|
|
81
|
+
const table = new Table({
|
|
82
|
+
head: [
|
|
83
|
+
chalk.bold.white('Check'),
|
|
84
|
+
chalk.bold.white('Estado'),
|
|
85
|
+
chalk.bold.white('Mensaje'),
|
|
86
|
+
chalk.bold.white('Solución'),
|
|
87
|
+
],
|
|
88
|
+
style: { head: [], border: [] },
|
|
89
|
+
chars: {
|
|
90
|
+
'top': '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
|
|
91
|
+
'bottom': '─', 'bottom-mid': '┴', 'bottom-left': '└', 'bottom-right': '┘',
|
|
92
|
+
'left': '│', 'left-mid': '├', 'mid': '─', 'mid-mid': '┼',
|
|
93
|
+
'right': '│', 'right-mid': '┤', 'middle': '│',
|
|
94
|
+
},
|
|
95
|
+
colWidths: [22, 12, 42, 30],
|
|
96
|
+
wordWrap: true,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
let okCount = 0
|
|
100
|
+
let warnCount = 0
|
|
101
|
+
let criticalCount = 0
|
|
102
|
+
|
|
103
|
+
for (const check of checks) {
|
|
104
|
+
const icon = statusIcon(check.status)
|
|
105
|
+
const label = statusLabel(check.status)
|
|
106
|
+
const name = `${icon} ${chalk.bold(check.name)}`
|
|
107
|
+
const message = check.message || ''
|
|
108
|
+
const fix = check.fix_cmd ? chalk.dim(check.fix_cmd) : ''
|
|
109
|
+
|
|
110
|
+
table.push([name, label, message, fix])
|
|
111
|
+
|
|
112
|
+
if (check.status === 'ok') okCount++
|
|
113
|
+
else if (check.status === 'warn') warnCount++
|
|
114
|
+
else if (check.status === 'critical') criticalCount++
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log()
|
|
118
|
+
console.log(table.toString())
|
|
119
|
+
|
|
120
|
+
// Summary line
|
|
121
|
+
const total = checks.length
|
|
122
|
+
const okStr = chalk.green(`${okCount}/${total} checks OK`)
|
|
123
|
+
const warnStr = warnCount > 0
|
|
124
|
+
? chalk.yellow(`${warnCount} advertencia${warnCount > 1 ? 's' : ''}`)
|
|
125
|
+
: chalk.dim('0 advertencias')
|
|
126
|
+
const critStr = criticalCount > 0
|
|
127
|
+
? chalk.red(`${criticalCount} crítico${criticalCount > 1 ? 's' : ''}`)
|
|
128
|
+
: chalk.dim('0 críticos')
|
|
129
|
+
|
|
130
|
+
console.log(` ${okStr} • ${warnStr} • ${critStr}`)
|
|
131
|
+
console.log()
|
|
132
|
+
|
|
133
|
+
// Exit with non-zero code if there are critical issues
|
|
134
|
+
if (criticalCount > 0) {
|
|
135
|
+
process.exitCode = 1
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
spinner.fail(chalk.red('Error al ejecutar diagnóstico'))
|
|
139
|
+
printError(err.message, err)
|
|
140
|
+
process.exitCode = 1
|
|
141
|
+
} finally {
|
|
142
|
+
if (client) client.close()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── tracker health ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* healthCommand — compact view of problems only.
|
|
150
|
+
*
|
|
151
|
+
* Filters out OK checks. Shows one line per issue.
|
|
152
|
+
* If everything is OK, prints a single success line.
|
|
153
|
+
*
|
|
154
|
+
* Example output (problems exist):
|
|
155
|
+
* ! swap_enabled Sin swap configurado
|
|
156
|
+
* ✖ last_backup Sin backups configurados tracker backup create
|
|
157
|
+
*
|
|
158
|
+
* Example output (all OK):
|
|
159
|
+
* ✔ Sistema saludable
|
|
160
|
+
*/
|
|
161
|
+
export async function healthCommand(_argv) {
|
|
162
|
+
const spinner = ora('Verificando sistema...').start()
|
|
163
|
+
let client = null
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const { checks, client: c } = await fetchChecks()
|
|
167
|
+
client = c
|
|
168
|
+
|
|
169
|
+
spinner.stop()
|
|
170
|
+
|
|
171
|
+
const problems = checks.filter((ch) => ch.status !== 'ok')
|
|
172
|
+
|
|
173
|
+
if (problems.length === 0) {
|
|
174
|
+
console.log()
|
|
175
|
+
console.log(` ${chalk.green('✔')} ${chalk.bold.green('Sistema saludable')}`)
|
|
176
|
+
console.log()
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log()
|
|
181
|
+
|
|
182
|
+
for (const check of problems) {
|
|
183
|
+
const icon = statusIcon(check.status)
|
|
184
|
+
const name = chalk.bold(check.name.padEnd(18))
|
|
185
|
+
const message = check.message || ''
|
|
186
|
+
const fix = check.fix_cmd ? ` ${chalk.dim(check.fix_cmd)}` : ''
|
|
187
|
+
|
|
188
|
+
console.log(` ${icon} ${name} ${message}${fix}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log()
|
|
192
|
+
|
|
193
|
+
// Exit code 1 if any critical
|
|
194
|
+
const hasCritical = problems.some((ch) => ch.status === 'critical')
|
|
195
|
+
if (hasCritical) {
|
|
196
|
+
process.exitCode = 1
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
spinner.fail(chalk.red('Error al verificar sistema'))
|
|
200
|
+
printError(err.message, err)
|
|
201
|
+
process.exitCode = 1
|
|
202
|
+
} finally {
|
|
203
|
+
if (client) client.close()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/cli/src/commands/login.js
|
|
3
|
+
*
|
|
4
|
+
* tracker login — browser-based auth (desktop / laptop)
|
|
5
|
+
* tracker login --key <k> — direct key auth (VPS / headless servers)
|
|
6
|
+
*
|
|
7
|
+
* Browser flow:
|
|
8
|
+
* 1. POST /api/v1/cli/auth/init/ → { session_id, auth_url }
|
|
9
|
+
* 2. Open auth_url in browser
|
|
10
|
+
* 3. Poll GET /api/v1/cli/auth/poll/{session_id}/ every 2s (timeout 5 min)
|
|
11
|
+
* 4. On complete: save ~/.logtrace/auth.json + write LOGTRACE_LICENSE_KEY to daemon .env
|
|
12
|
+
*
|
|
13
|
+
* Key flow:
|
|
14
|
+
* 1. POST /api/v1/cli/auth/key-login/ { license_key }
|
|
15
|
+
* 2. On success: same save/write as browser flow
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import open from 'open'
|
|
22
|
+
import ora from 'ora'
|
|
23
|
+
import chalk from 'chalk'
|
|
24
|
+
import { LOGTRACE_DIR } from '@logtrace/shared/constants'
|
|
25
|
+
|
|
26
|
+
// -------------------------------------------------------------------
|
|
27
|
+
// Config
|
|
28
|
+
// -------------------------------------------------------------------
|
|
29
|
+
const AUTH_FILE = join(homedir(), '.logtrace', 'auth.json')
|
|
30
|
+
const POLL_INTERVAL = 2_000 // 2 seconds
|
|
31
|
+
const POLL_TIMEOUT = 5 * 60 * 1_000 // 5 minutes
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the central API URL.
|
|
35
|
+
* Priority: process.env > daemon .env > production default.
|
|
36
|
+
*/
|
|
37
|
+
function _getCentralApiUrl() {
|
|
38
|
+
if (process.env.CENTRAL_API_URL) return process.env.CENTRAL_API_URL
|
|
39
|
+
|
|
40
|
+
const envPath = join(LOGTRACE_DIR, '.env')
|
|
41
|
+
if (existsSync(envPath)) {
|
|
42
|
+
const lines = readFileSync(envPath, 'utf8').split('\n')
|
|
43
|
+
const line = lines.find((l) => l.startsWith('CENTRAL_API_URL='))
|
|
44
|
+
if (line) return line.slice('CENTRAL_API_URL='.length).trim()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return 'https://api.logtrace.cloud'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// -------------------------------------------------------------------
|
|
51
|
+
// Main command
|
|
52
|
+
// -------------------------------------------------------------------
|
|
53
|
+
export async function loginCommand(argv) {
|
|
54
|
+
const key = argv.key || argv.k || null
|
|
55
|
+
|
|
56
|
+
if (key) {
|
|
57
|
+
await _loginWithKey(key)
|
|
58
|
+
} else {
|
|
59
|
+
await _loginWithBrowser()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// -------------------------------------------------------------------
|
|
64
|
+
// Key-based login (for VPS / headless)
|
|
65
|
+
// -------------------------------------------------------------------
|
|
66
|
+
async function _loginWithKey(licenseKey) {
|
|
67
|
+
const CENTRAL_API_URL = _getCentralApiUrl()
|
|
68
|
+
const spinner = ora('Verifying license key...').start()
|
|
69
|
+
|
|
70
|
+
let data
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(`${CENTRAL_API_URL}/api/v1/cli/auth/key-login/`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({ license_key: licenseKey }),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
data = await res.json()
|
|
79
|
+
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
const msg = data.message || data.detail || 'Invalid or inactive license key.'
|
|
82
|
+
spinner.fail(msg)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
spinner.fail(`Could not connect to server: ${err.message}`)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
spinner.stop()
|
|
91
|
+
|
|
92
|
+
_saveAuth(data)
|
|
93
|
+
_writeKeyToEnv(data.license_key)
|
|
94
|
+
|
|
95
|
+
console.log(chalk.green(`\n Logged in as ${data.user.email}`))
|
|
96
|
+
console.log(
|
|
97
|
+
chalk.dim(` Plan: ${data.user.plan} • License: ${data.license_key.slice(0, 12)}...`),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// -------------------------------------------------------------------
|
|
102
|
+
// Browser-based login (for desktop)
|
|
103
|
+
// -------------------------------------------------------------------
|
|
104
|
+
async function _loginWithBrowser() {
|
|
105
|
+
const CENTRAL_API_URL = _getCentralApiUrl()
|
|
106
|
+
|
|
107
|
+
// ── Step 1: Init session ──────────────────────────────────────────
|
|
108
|
+
const initSpinner = ora('Connecting to LogTrace...').start()
|
|
109
|
+
|
|
110
|
+
let session
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetch(`${CENTRAL_API_URL}/api/v1/cli/auth/init/`, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
})
|
|
116
|
+
if (!res.ok) throw new Error(`Server returned HTTP ${res.status}`)
|
|
117
|
+
session = await res.json()
|
|
118
|
+
} catch (err) {
|
|
119
|
+
initSpinner.fail(`Could not connect to server: ${err.message}`)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
initSpinner.stop()
|
|
124
|
+
|
|
125
|
+
// ── Step 2: Open browser ─────────────────────────────────────────
|
|
126
|
+
console.log(chalk.cyan('\nOpening browser to log in...'))
|
|
127
|
+
console.log(chalk.dim(` If the browser doesn't open automatically:\n ${session.auth_url}\n`))
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await open(session.auth_url)
|
|
131
|
+
} catch {
|
|
132
|
+
// Graceful degradation — the user already has the manual URL above
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Step 3: Poll for completion ───────────────────────────────────
|
|
136
|
+
const pollSpinner = ora('Waiting for browser authentication...').start()
|
|
137
|
+
const deadline = Date.now() + POLL_TIMEOUT
|
|
138
|
+
|
|
139
|
+
while (Date.now() < deadline) {
|
|
140
|
+
await _sleep(POLL_INTERVAL)
|
|
141
|
+
|
|
142
|
+
let res, data
|
|
143
|
+
try {
|
|
144
|
+
res = await fetch(`${CENTRAL_API_URL}/api/v1/cli/auth/poll/${session.session_id}/`)
|
|
145
|
+
data = await res.json()
|
|
146
|
+
} catch {
|
|
147
|
+
// Network hiccup — keep polling
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (res.status === 404) {
|
|
152
|
+
pollSpinner.fail('Session expired. Run tracker login to try again.')
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
// Unexpected error — log and keep polling rather than silently looping
|
|
158
|
+
pollSpinner.text = `Waiting for browser authentication... (server: ${res.status})`
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (data.status === 'complete') {
|
|
163
|
+
pollSpinner.stop()
|
|
164
|
+
|
|
165
|
+
// ── Step 4: Persist credentials ───────────────────────────────
|
|
166
|
+
_saveAuth(data)
|
|
167
|
+
_writeKeyToEnv(data.license_key)
|
|
168
|
+
|
|
169
|
+
console.log(chalk.green(`\n Logged in as ${data.user.email}`))
|
|
170
|
+
console.log(
|
|
171
|
+
chalk.dim(` Plan: ${data.user.plan} • License: ${data.license_key.slice(0, 12)}...`),
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// status === 'pending' — keep waiting
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
pollSpinner.fail('Timed out waiting for login. Run tracker login to try again.')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// -------------------------------------------------------------------
|
|
183
|
+
// Helpers
|
|
184
|
+
// -------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
export function _saveAuthData(data) {
|
|
187
|
+
const dir = join(homedir(), '.logtrace')
|
|
188
|
+
mkdirSync(dir, { recursive: true })
|
|
189
|
+
|
|
190
|
+
const payload = {
|
|
191
|
+
email: data.user.email,
|
|
192
|
+
name: data.user.name,
|
|
193
|
+
plan: data.user.plan,
|
|
194
|
+
license_key: data.license_key,
|
|
195
|
+
saved_at: new Date().toISOString(),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
writeFileSync(AUTH_FILE, JSON.stringify(payload, null, 2), { mode: 0o600 })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// keep the old private name pointing to the export so internal calls still work
|
|
202
|
+
const _saveAuth = _saveAuthData
|
|
203
|
+
|
|
204
|
+
export function _writeKeyToEnv(licenseKey) {
|
|
205
|
+
const envPath = join(LOGTRACE_DIR, '.env')
|
|
206
|
+
if (!existsSync(envPath)) return
|
|
207
|
+
|
|
208
|
+
const content = readFileSync(envPath, 'utf8')
|
|
209
|
+
const lines = content.split('\n')
|
|
210
|
+
const idx = lines.findIndex((l) => l.startsWith('LOGTRACE_LICENSE_KEY='))
|
|
211
|
+
|
|
212
|
+
if (idx !== -1) {
|
|
213
|
+
lines[idx] = `LOGTRACE_LICENSE_KEY=${licenseKey}`
|
|
214
|
+
} else {
|
|
215
|
+
if (lines[lines.length - 1] !== '') {
|
|
216
|
+
lines.push(`LOGTRACE_LICENSE_KEY=${licenseKey}`)
|
|
217
|
+
} else {
|
|
218
|
+
lines.splice(lines.length - 1, 0, `LOGTRACE_LICENSE_KEY=${licenseKey}`)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
writeFileSync(envPath, lines.join('\n'), { mode: 0o640 })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _sleep(ms) {
|
|
226
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
227
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/cli/src/commands/logs.js
|
|
3
|
+
*
|
|
4
|
+
* tracker logs list — query stored logs with optional filters
|
|
5
|
+
* tracker logs search <query> — full-text search over message + service
|
|
6
|
+
*
|
|
7
|
+
* The --from flag accepts human-friendly durations (1h, 30m, 2d).
|
|
8
|
+
* Parsing is intentionally simple: no external library needed for the
|
|
9
|
+
* values we expect in practice.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import chalk from 'chalk'
|
|
13
|
+
import { createClient } from '../client.js'
|
|
14
|
+
import { printError } from '../ui/renderer.js'
|
|
15
|
+
import { formatDate } from '@logtrace/shared/utils'
|
|
16
|
+
|
|
17
|
+
// ── Level colors ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const LEVEL_COLOR = {
|
|
20
|
+
debug: (s) => chalk.gray(s),
|
|
21
|
+
info: (s) => chalk.cyan(s),
|
|
22
|
+
warn: (s) => chalk.yellow(s),
|
|
23
|
+
error: (s) => chalk.red(s),
|
|
24
|
+
critical: (s) => chalk.bgRed.white(s),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function colorLevel(level) {
|
|
28
|
+
const fn = LEVEL_COLOR[level] ?? ((s) => s)
|
|
29
|
+
return fn(`[${level.toUpperCase().padEnd(8)}]`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Duration parser ───────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* parseDuration(str) — convert "1h", "30m", "2d" to milliseconds.
|
|
36
|
+
* Returns null if the string doesn't match any known pattern.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} str
|
|
39
|
+
* @returns {number|null}
|
|
40
|
+
*/
|
|
41
|
+
function parseDuration(str) {
|
|
42
|
+
if (!str || typeof str !== 'string') return null
|
|
43
|
+
const match = str.trim().match(/^(\d+)(s|m|h|d)$/)
|
|
44
|
+
if (!match) return null
|
|
45
|
+
const value = parseInt(match[1], 10)
|
|
46
|
+
const unit = match[2]
|
|
47
|
+
const multipliers = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
|
|
48
|
+
return value * multipliers[unit]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Output renderer ───────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* printLogs(logs) — render a list of log entries as a plain text table.
|
|
55
|
+
*
|
|
56
|
+
* Columns: timestamp level service message
|
|
57
|
+
* Widths are fixed so the output is scannable without a table library.
|
|
58
|
+
*
|
|
59
|
+
* @param {object[]} logs
|
|
60
|
+
*/
|
|
61
|
+
function printLogs(logs) {
|
|
62
|
+
if (!logs || logs.length === 0) {
|
|
63
|
+
console.log(chalk.dim(' No hay logs que coincidan con los filtros.'))
|
|
64
|
+
console.log()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log()
|
|
69
|
+
for (const log of logs) {
|
|
70
|
+
const ts = formatDate(log.timestamp)
|
|
71
|
+
const level = colorLevel(log.level)
|
|
72
|
+
const service = chalk.dim((log.service || '-').padEnd(12).slice(0, 12))
|
|
73
|
+
const message = log.message
|
|
74
|
+
|
|
75
|
+
console.log(`${chalk.dim(ts)} ${level} ${service} ${message}`)
|
|
76
|
+
}
|
|
77
|
+
console.log()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Command handlers ──────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
export async function logsListCommand(argv) {
|
|
83
|
+
const args = {}
|
|
84
|
+
|
|
85
|
+
if (argv.level) args.level = argv.level
|
|
86
|
+
if (argv.service) args.service = argv.service
|
|
87
|
+
if (argv.limit) args.limit = Number(argv.limit)
|
|
88
|
+
|
|
89
|
+
if (argv.from) {
|
|
90
|
+
const ms = parseDuration(String(argv.from))
|
|
91
|
+
if (ms === null) {
|
|
92
|
+
printError(`Formato de --from inválido: "${argv.from}". Usar: 1h, 30m, 1d`)
|
|
93
|
+
process.exitCode = 1
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
args.from = Date.now() - ms
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const client = createClient()
|
|
100
|
+
try {
|
|
101
|
+
await client.connect()
|
|
102
|
+
const logs = await client.send('logs.query', args)
|
|
103
|
+
printLogs(logs)
|
|
104
|
+
} catch (err) {
|
|
105
|
+
printError(`Error al consultar logs: ${err.message}`)
|
|
106
|
+
process.exitCode = 1
|
|
107
|
+
} finally {
|
|
108
|
+
client.close()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function logsSearchCommand(argv) {
|
|
113
|
+
const args = {
|
|
114
|
+
q: String(argv.query || ''),
|
|
115
|
+
limit: argv.limit ? Number(argv.limit) : 20,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!args.q.trim()) {
|
|
119
|
+
printError('El argumento <query> no puede estar vacío.')
|
|
120
|
+
process.exitCode = 1
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const client = createClient()
|
|
125
|
+
try {
|
|
126
|
+
await client.connect()
|
|
127
|
+
const logs = await client.send('logs.search', args)
|
|
128
|
+
printLogs(logs)
|
|
129
|
+
} catch (err) {
|
|
130
|
+
printError(`Error al buscar logs: ${err.message}`)
|
|
131
|
+
process.exitCode = 1
|
|
132
|
+
} finally {
|
|
133
|
+
client.close()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/cli/src/commands/status.js
|
|
3
|
+
*
|
|
4
|
+
* tracker status — show daemon version, uptime, and active modules.
|
|
5
|
+
*
|
|
6
|
+
* Example output:
|
|
7
|
+
*
|
|
8
|
+
* LogTrace v0.1.0 | uptime: 3m 12s | modules: 0
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createClient } from '../client.js'
|
|
12
|
+
import { printStatus, printError } from '../ui/renderer.js'
|
|
13
|
+
|
|
14
|
+
export async function statusCommand(_argv) {
|
|
15
|
+
const client = createClient()
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await client.connect()
|
|
19
|
+
const data = await client.send('status', {})
|
|
20
|
+
printStatus(data)
|
|
21
|
+
} catch (err) {
|
|
22
|
+
printError(`Cannot reach daemon: ${err.message}`)
|
|
23
|
+
printError('Run "npm run dev:daemon" to start the daemon first.')
|
|
24
|
+
process.exitCode = 1
|
|
25
|
+
} finally {
|
|
26
|
+
client.close()
|
|
27
|
+
}
|
|
28
|
+
}
|