@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.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/CHANGELOG.md +63 -1
- package/README.md +235 -30
- package/bin/thinr.js +43 -25
- package/commands/_shared.js +154 -0
- package/commands/dashboard.js +17 -0
- package/commands/device/_shared.js +17 -34
- package/commands/device/console.js +1 -0
- package/commands/device/delete.js +53 -0
- package/commands/device/exec.js +7 -6
- package/commands/device/fs.js +193 -0
- package/commands/device/index.js +9 -0
- package/commands/device/list.js +35 -23
- package/commands/device/logs.js +178 -0
- package/commands/device/property.js +30 -28
- package/commands/device/proxy.js +3 -0
- package/commands/device/resource.js +30 -29
- package/commands/device/status.js +35 -14
- package/commands/device/transfer.js +236 -0
- package/commands/device/update.js +15 -10
- package/commands/fleet/index.js +17 -0
- package/commands/fleet/upgrade.js +275 -0
- package/commands/logout.js +10 -11
- package/commands/playbook/_shared.js +12 -0
- package/commands/playbook/index.js +18 -0
- package/commands/playbook/run.js +302 -0
- package/commands/playbook/validate.js +38 -0
- package/commands/product/_shared.js +8 -0
- package/commands/product/exec.js +243 -0
- package/commands/product/fs.js +218 -0
- package/commands/product/index.js +35 -0
- package/commands/product/list.js +86 -0
- package/commands/product/logs.js +289 -0
- package/commands/product/metric.js +124 -0
- package/commands/product/playbook.js +911 -0
- package/commands/product/property.js +76 -0
- package/commands/product/props.js +125 -0
- package/commands/product/push.js +184 -0
- package/commands/product/resource.js +76 -0
- package/commands/product/status.js +213 -0
- package/commands/profile.js +76 -8
- package/dist/dashboard.js +5644 -0
- package/lib/agent-releases.js +59 -0
- package/lib/agent-versions.js +73 -0
- package/lib/alarms.js +252 -0
- package/lib/api.js +8 -2
- package/lib/auth.js +68 -20
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +213 -0
- package/lib/config.js +5 -3
- package/lib/console.js +19 -5
- package/lib/constants.js +83 -0
- package/lib/dashboard/event-stream.js +239 -0
- package/lib/dashboard/metric-aggregate.js +64 -0
- package/lib/dashboard/metric-filter.js +39 -0
- package/lib/debug-log.js +93 -0
- package/lib/device-api.js +131 -148
- package/lib/devices.js +18 -21
- package/lib/errors.js +12 -0
- package/lib/fleet-upgrade.js +106 -0
- package/lib/format.js +114 -0
- package/lib/mcp/registry.js +14 -0
- package/lib/mcp/tools-alarms.js +510 -0
- package/lib/mcp/tools-buckets.js +103 -0
- package/lib/mcp/tools-devices.js +33 -3
- package/lib/mcp/tools-exec.js +30 -17
- package/lib/mcp/tools-filesystem.js +102 -6
- package/lib/mcp/tools-install.js +153 -0
- package/lib/mcp/tools-logs.js +225 -0
- package/lib/mcp/tools-playbook.js +211 -0
- package/lib/mcp/tools-products.js +1226 -105
- package/lib/mcp/tools-profile.js +634 -0
- package/lib/mcp/tools-tokens.js +371 -0
- package/lib/monitoring.js +26 -1
- package/lib/playbook/actions.js +447 -0
- package/lib/playbook/expression.js +302 -0
- package/lib/playbook/fleet.js +175 -0
- package/lib/playbook/loader.js +247 -0
- package/lib/playbook/runner.js +300 -0
- package/lib/playbook/schema.js +209 -0
- package/lib/playbook/vars.js +209 -0
- package/lib/product/log-presets.js +196 -0
- package/lib/product/logs.js +485 -0
- package/lib/product-orchestrator.js +124 -0
- package/lib/product.js +523 -20
- package/lib/profile.js +665 -0
- package/lib/property.js +7 -40
- package/lib/proxy.js +9 -8
- package/lib/resource.js +10 -14
- package/lib/status.js +78 -37
- package/lib/tokens.js +374 -0
- package/package.json +14 -3
- package/commands/product.js +0 -407
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Small helper that talks to the public release CDN at
|
|
5
|
+
* `https://get.thinremote.io/<channel>.json`. Same file the agent itself
|
|
6
|
+
* polls to decide whether a self-update is available — keeping a single
|
|
7
|
+
* source of truth between what the CLI offers and what a device would
|
|
8
|
+
* actually download if the user ran `thinr device update apply`.
|
|
9
|
+
*
|
|
10
|
+
* Why a dedicated module: the TUI hook (`useLatestAgentVersion`) needed
|
|
11
|
+
* React-aware polling, the CLI just wants a one-shot fetch. Sharing the
|
|
12
|
+
* URL and the response shape here avoids drift if we ever add fields
|
|
13
|
+
* (signing keys, min-supported-version, etc.) to the JSON.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const CDN_BASE = 'https://get.thinremote.io';
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fetch the release descriptor for a channel.
|
|
21
|
+
* @param {object} [opts]
|
|
22
|
+
* @param {string} [opts.channel='latest'] Release channel — matches the
|
|
23
|
+
* filenames under get.thinremote.io (`latest.json`, `main.json`,
|
|
24
|
+
* `develop.json`). Per-version archives are not exposed here; devices
|
|
25
|
+
* resolve those through the channel metadata themselves.
|
|
26
|
+
* @param {number} [opts.timeoutMs] Hard cap on the network call. 5s is
|
|
27
|
+
* plenty for a 1 KB JSON off a global CDN; anything slower is a DNS or
|
|
28
|
+
* connectivity issue that should fail fast rather than stall the CLI.
|
|
29
|
+
* @returns {Promise<{version: string, checksums?: Record<string,string>}>}
|
|
30
|
+
*/
|
|
31
|
+
export async function fetchReleaseDescriptor({
|
|
32
|
+
channel = 'latest',
|
|
33
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
34
|
+
} = {}) {
|
|
35
|
+
const url = `${CDN_BASE}/${encodeURIComponent(channel)}.json`;
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const to = setTimeout(() => controller.abort(), timeoutMs);
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`release descriptor fetch failed: ${res.status} ${res.statusText}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
if (!data?.version) {
|
|
47
|
+
throw new Error('release descriptor missing `version` field');
|
|
48
|
+
}
|
|
49
|
+
return data;
|
|
50
|
+
} finally {
|
|
51
|
+
clearTimeout(to);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Convenience: just the version string. */
|
|
56
|
+
export async function fetchLatestAgentVersion(opts) {
|
|
57
|
+
const { version } = await fetchReleaseDescriptor(opts);
|
|
58
|
+
return version;
|
|
59
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent-version helpers shared between the TUI (`src/dashboard/`) and the
|
|
5
|
+
* CLI commands. Kept in plain Node-compatible JS so both the bundled
|
|
6
|
+
* dashboard and the un-bundled CLI can import directly.
|
|
7
|
+
*
|
|
8
|
+
* The agent reports its version as a git-describe string (e.g.
|
|
9
|
+
* `v1.6.5-2-g7c3d192` for a dev build off `v1.6.5`). Grouping those
|
|
10
|
+
* together by release tag gives a readable rollout picture without an
|
|
11
|
+
* explosion of near-identical rows.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Drop the git-describe suffix so dev builds group with their release. */
|
|
15
|
+
export function normalizeAgentVersion(raw) {
|
|
16
|
+
if (!raw) return null;
|
|
17
|
+
const m = String(raw).match(/^(v?\d+\.\d+\.\d+)/);
|
|
18
|
+
return m ? m[1] : raw;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function semverTriple(v) {
|
|
22
|
+
if (!v) return null;
|
|
23
|
+
const m = String(v).match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Descending comparator — newer first. Non-semver values sink to the
|
|
30
|
+
* bottom alphabetically, so rogue strings never claim "newest".
|
|
31
|
+
*/
|
|
32
|
+
export function compareAgentVersions(a, b) {
|
|
33
|
+
const ta = semverTriple(a);
|
|
34
|
+
const tb = semverTriple(b);
|
|
35
|
+
if (ta && tb) {
|
|
36
|
+
for (let i = 0; i < 3; i++) {
|
|
37
|
+
if (ta[i] !== tb[i]) return tb[i] - ta[i];
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
if (ta) return -1;
|
|
42
|
+
if (tb) return 1;
|
|
43
|
+
return String(b).localeCompare(String(a));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Partition a map of `deviceId -> reported-version-string` against a
|
|
48
|
+
* target version.
|
|
49
|
+
*
|
|
50
|
+
* Returns:
|
|
51
|
+
* - `outdated`: device IDs strictly older than `target`
|
|
52
|
+
* - `current`: device IDs already at `target`
|
|
53
|
+
* - `unknown`: device IDs we have no version for (never reported a
|
|
54
|
+
* sample, or reported a non-parseable string). These are *not*
|
|
55
|
+
* upgraded by default — we don't want to "fix" something we can't
|
|
56
|
+
* identify.
|
|
57
|
+
*/
|
|
58
|
+
export function classifyAgainst(target, deviceVersions) {
|
|
59
|
+
const outdated = [];
|
|
60
|
+
const current = [];
|
|
61
|
+
const unknown = [];
|
|
62
|
+
for (const [id, raw] of Object.entries(deviceVersions)) {
|
|
63
|
+
const v = normalizeAgentVersion(raw);
|
|
64
|
+
if (!v) {
|
|
65
|
+
unknown.push(id);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const d = compareAgentVersions(v, target);
|
|
69
|
+
if (d > 0) outdated.push(id);
|
|
70
|
+
else current.push(id);
|
|
71
|
+
}
|
|
72
|
+
return { outdated, current, unknown };
|
|
73
|
+
}
|
package/lib/alarms.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import api from './api.js';
|
|
2
|
+
import { apiError } from './errors.js';
|
|
3
|
+
import { requireConfig } from './config.js';
|
|
4
|
+
|
|
5
|
+
function resolveUser(user) {
|
|
6
|
+
return user || requireConfig().username;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Backend enums (see backend/src/thinger/features/alarms/alarm_rule.hpp).
|
|
10
|
+
// Exported so the dashboard can render labels/colors without re-deriving
|
|
11
|
+
// them from string heuristics.
|
|
12
|
+
export const ALARM_STATE = {
|
|
13
|
+
NONE: 0,
|
|
14
|
+
ACTIVATED: 1,
|
|
15
|
+
ACKNOWLEDGED: 2,
|
|
16
|
+
LATCHED: 3,
|
|
17
|
+
SHELVED: 4,
|
|
18
|
+
CLEARED: 5,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const ALARM_SEVERITY = {
|
|
22
|
+
NONE: 0,
|
|
23
|
+
LOW: 1,
|
|
24
|
+
MEDIUM: 2,
|
|
25
|
+
HIGH: 3,
|
|
26
|
+
CRITICAL: 4,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const ALARM_STATE_LABEL = {
|
|
30
|
+
0: 'none',
|
|
31
|
+
1: 'active',
|
|
32
|
+
2: 'ack',
|
|
33
|
+
3: 'latched',
|
|
34
|
+
4: 'shelved',
|
|
35
|
+
5: 'cleared',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const ALARM_SEVERITY_LABEL = {
|
|
39
|
+
0: 'none',
|
|
40
|
+
1: 'low',
|
|
41
|
+
2: 'med',
|
|
42
|
+
3: 'high',
|
|
43
|
+
4: 'crit',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Reverse maps so callers can pass human-readable strings ("ack", "shelved",
|
|
47
|
+
// "high", …) instead of remembering the numeric enum values.
|
|
48
|
+
const STATE_FROM_LABEL = {
|
|
49
|
+
none: ALARM_STATE.NONE,
|
|
50
|
+
active: ALARM_STATE.ACTIVATED,
|
|
51
|
+
activated: ALARM_STATE.ACTIVATED,
|
|
52
|
+
ack: ALARM_STATE.ACKNOWLEDGED,
|
|
53
|
+
acknowledged: ALARM_STATE.ACKNOWLEDGED,
|
|
54
|
+
latched: ALARM_STATE.LATCHED,
|
|
55
|
+
shelved: ALARM_STATE.SHELVED,
|
|
56
|
+
cleared: ALARM_STATE.CLEARED,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const SEVERITY_FROM_LABEL = {
|
|
60
|
+
none: ALARM_SEVERITY.NONE,
|
|
61
|
+
low: ALARM_SEVERITY.LOW,
|
|
62
|
+
med: ALARM_SEVERITY.MEDIUM,
|
|
63
|
+
medium: ALARM_SEVERITY.MEDIUM,
|
|
64
|
+
high: ALARM_SEVERITY.HIGH,
|
|
65
|
+
crit: ALARM_SEVERITY.CRITICAL,
|
|
66
|
+
critical: ALARM_SEVERITY.CRITICAL,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function parseAlarmState(value) {
|
|
70
|
+
if (value == null) return undefined;
|
|
71
|
+
if (typeof value === 'number') return value;
|
|
72
|
+
const key = String(value).trim().toLowerCase();
|
|
73
|
+
return STATE_FROM_LABEL[key];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseAlarmSeverity(value) {
|
|
77
|
+
if (value == null) return undefined;
|
|
78
|
+
if (typeof value === 'number') return value;
|
|
79
|
+
const key = String(value).trim().toLowerCase();
|
|
80
|
+
return SEVERITY_FROM_LABEL[key];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Repeats a query param as the server expects (`?state=1&state=2`).
|
|
84
|
+
function appendMulti(params, key, values) {
|
|
85
|
+
if (values == null) return;
|
|
86
|
+
const arr = Array.isArray(values) ? values : [values];
|
|
87
|
+
for (const v of arr) params.append(key, String(v));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Query alarm instances from /v1/users/{user}/alarms/instances.
|
|
92
|
+
*
|
|
93
|
+
* `state` and `severity` are repeated query params — pass numbers or
|
|
94
|
+
* arrays of numbers (matching the ALARM_STATE / ALARM_SEVERITY enums).
|
|
95
|
+
*
|
|
96
|
+
* @param {{
|
|
97
|
+
* user?: string | null,
|
|
98
|
+
* count?: number,
|
|
99
|
+
* index?: number,
|
|
100
|
+
* name?: string,
|
|
101
|
+
* state?: number | number[],
|
|
102
|
+
* severity?: number | number[],
|
|
103
|
+
* sort?: string,
|
|
104
|
+
* order?: string,
|
|
105
|
+
* }} [opts]
|
|
106
|
+
*/
|
|
107
|
+
export async function getAlarmInstances({
|
|
108
|
+
user = null,
|
|
109
|
+
count = 100,
|
|
110
|
+
index = 0,
|
|
111
|
+
name,
|
|
112
|
+
state,
|
|
113
|
+
severity,
|
|
114
|
+
sort,
|
|
115
|
+
order,
|
|
116
|
+
} = {}) {
|
|
117
|
+
const apiUser = resolveUser(user);
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
params.set('count', String(count));
|
|
120
|
+
params.set('index', String(index));
|
|
121
|
+
if (name) params.set('name', name);
|
|
122
|
+
if (sort) params.set('sort', sort);
|
|
123
|
+
if (order) params.set('order', order);
|
|
124
|
+
appendMulti(params, 'state', state);
|
|
125
|
+
appendMulti(params, 'severity', severity);
|
|
126
|
+
try {
|
|
127
|
+
const res = await api.get(
|
|
128
|
+
`/v1/users/${apiUser}/alarms/instances?${params.toString()}`,
|
|
129
|
+
);
|
|
130
|
+
return Array.isArray(res.data) ? res.data : [];
|
|
131
|
+
} catch (e) {
|
|
132
|
+
throw apiError(e);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Counter per state, e.g. { activated: 3, acknowledged: 1, ... }. The
|
|
137
|
+
// server seeds zeros for every known state so consumers can render a
|
|
138
|
+
// stable layout without null-checks.
|
|
139
|
+
export async function getAlarmInstanceStats({ user = null } = {}) {
|
|
140
|
+
const apiUser = resolveUser(user);
|
|
141
|
+
try {
|
|
142
|
+
const res = await api.get(`/v1/users/${apiUser}/alarms/instances/stats`);
|
|
143
|
+
return res.data || {};
|
|
144
|
+
} catch (e) {
|
|
145
|
+
throw apiError(e);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function getAlarmInstance(instance, { user = null } = {}) {
|
|
150
|
+
const apiUser = resolveUser(user);
|
|
151
|
+
try {
|
|
152
|
+
const res = await api.get(
|
|
153
|
+
`/v1/users/${apiUser}/alarms/instances/${encodeURIComponent(instance)}`,
|
|
154
|
+
);
|
|
155
|
+
return res.data;
|
|
156
|
+
} catch (e) {
|
|
157
|
+
throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// PUT-style update. The server accepts only a small whitelist of fields
|
|
162
|
+
// (see UPDATE_SCHEMA in alarms_instances.cpp): name, description, state,
|
|
163
|
+
// annotation, reactivation. Anything else is rejected.
|
|
164
|
+
export async function updateAlarmInstance(instance, body, { user = null } = {}) {
|
|
165
|
+
const apiUser = resolveUser(user);
|
|
166
|
+
try {
|
|
167
|
+
const res = await api.put(
|
|
168
|
+
`/v1/users/${apiUser}/alarms/instances/${encodeURIComponent(instance)}`,
|
|
169
|
+
body,
|
|
170
|
+
);
|
|
171
|
+
return res.data;
|
|
172
|
+
} catch (e) {
|
|
173
|
+
throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function deleteAlarmInstance(instance, { user = null } = {}) {
|
|
178
|
+
const apiUser = resolveUser(user);
|
|
179
|
+
try {
|
|
180
|
+
await api.delete(
|
|
181
|
+
`/v1/users/${apiUser}/alarms/instances/${encodeURIComponent(instance)}`,
|
|
182
|
+
);
|
|
183
|
+
return true;
|
|
184
|
+
} catch (e) {
|
|
185
|
+
throw apiError(e, { notFound: `Alarm instance not found: ${instance}` });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function getAlarmRules({ user = null, count = 200, index = 0 } = {}) {
|
|
190
|
+
const apiUser = resolveUser(user);
|
|
191
|
+
const params = new URLSearchParams();
|
|
192
|
+
params.set('count', String(count));
|
|
193
|
+
params.set('index', String(index));
|
|
194
|
+
try {
|
|
195
|
+
const res = await api.get(
|
|
196
|
+
`/v1/users/${apiUser}/alarms/rules?${params.toString()}`,
|
|
197
|
+
);
|
|
198
|
+
return Array.isArray(res.data) ? res.data : [];
|
|
199
|
+
} catch (e) {
|
|
200
|
+
throw apiError(e);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function getAlarmRule(rule, { user = null } = {}) {
|
|
205
|
+
const apiUser = resolveUser(user);
|
|
206
|
+
try {
|
|
207
|
+
const res = await api.get(
|
|
208
|
+
`/v1/users/${apiUser}/alarms/rules/${encodeURIComponent(rule)}`,
|
|
209
|
+
);
|
|
210
|
+
return res.data;
|
|
211
|
+
} catch (e) {
|
|
212
|
+
throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// POST /v1/users/{user}/alarms/rules. The body must include `rule`,
|
|
217
|
+
// `name`, `enabled` and an optional `config` object — see CREATE_SCHEMA
|
|
218
|
+
// in alarms_rules.cpp for the full shape.
|
|
219
|
+
export async function createAlarmRule(body, { user = null } = {}) {
|
|
220
|
+
const apiUser = resolveUser(user);
|
|
221
|
+
try {
|
|
222
|
+
const res = await api.post(`/v1/users/${apiUser}/alarms/rules`, body);
|
|
223
|
+
return res.data;
|
|
224
|
+
} catch (e) {
|
|
225
|
+
throw apiError(e);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function updateAlarmRule(rule, body, { user = null } = {}) {
|
|
230
|
+
const apiUser = resolveUser(user);
|
|
231
|
+
try {
|
|
232
|
+
const res = await api.put(
|
|
233
|
+
`/v1/users/${apiUser}/alarms/rules/${encodeURIComponent(rule)}`,
|
|
234
|
+
body,
|
|
235
|
+
);
|
|
236
|
+
return res.data;
|
|
237
|
+
} catch (e) {
|
|
238
|
+
throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function deleteAlarmRule(rule, { user = null } = {}) {
|
|
243
|
+
const apiUser = resolveUser(user);
|
|
244
|
+
try {
|
|
245
|
+
await api.delete(
|
|
246
|
+
`/v1/users/${apiUser}/alarms/rules/${encodeURIComponent(rule)}`,
|
|
247
|
+
);
|
|
248
|
+
return true;
|
|
249
|
+
} catch (e) {
|
|
250
|
+
throw apiError(e, { notFound: `Alarm rule not found: ${rule}` });
|
|
251
|
+
}
|
|
252
|
+
}
|
package/lib/api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import { refreshToken, getAccessToken } from './auth.js';
|
|
3
3
|
import { readConfig } from './config.js';
|
|
4
|
+
import { TIMEOUTS } from './constants.js';
|
|
4
5
|
import http from 'http';
|
|
5
6
|
import https from 'https';
|
|
6
7
|
|
|
@@ -13,7 +14,7 @@ const insecureAgent = new https.Agent({ keepAlive: true, rejectUnauthorized: fal
|
|
|
13
14
|
|
|
14
15
|
const api = axios.create({
|
|
15
16
|
// You can leave baseURL undefined initially
|
|
16
|
-
timeout:
|
|
17
|
+
timeout: TIMEOUTS.API_DEFAULT_MS,
|
|
17
18
|
httpAgent,
|
|
18
19
|
httpsAgent,
|
|
19
20
|
});
|
|
@@ -31,6 +32,9 @@ api.interceptors.request.use(
|
|
|
31
32
|
|
|
32
33
|
// Allow self-signed TLS against localhost / 127.0.0.1 or when the user
|
|
33
34
|
// opts in explicitly. Anything else keeps default certificate validation.
|
|
35
|
+
// A malformed URL here is not worth aborting the request over — the
|
|
36
|
+
// HTTPS layer will surface the real error downstream; we only care
|
|
37
|
+
// about detecting localhost cheaply.
|
|
34
38
|
try {
|
|
35
39
|
const base = request.baseURL || api.defaults.baseURL || '';
|
|
36
40
|
const url = new URL(request.url || '', base || 'https://placeholder/');
|
|
@@ -38,7 +42,9 @@ api.interceptors.request.use(
|
|
|
38
42
|
if (url.protocol === 'https:' && (isLocal || process.env.THINR_INSECURE === '1')) {
|
|
39
43
|
request.httpsAgent = insecureAgent;
|
|
40
44
|
}
|
|
41
|
-
} catch {
|
|
45
|
+
} catch {
|
|
46
|
+
// Intentionally swallowed — see note above.
|
|
47
|
+
}
|
|
42
48
|
|
|
43
49
|
return request;
|
|
44
50
|
},
|
package/lib/auth.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { input, select } from '@inquirer/prompts';
|
|
3
|
-
import chalk from 'chalk';
|
|
4
3
|
import ora from 'ora';
|
|
5
4
|
import open, { apps } from 'open';
|
|
6
5
|
import { writeConfig, readConfig, mergeConfig } from './config.js';
|
|
6
|
+
import { label, success, warning, error as errorStyle, info } from './format.js';
|
|
7
7
|
|
|
8
8
|
import api, { setBaseURL } from './api.js';
|
|
9
|
+
import { OAUTH, TIMEOUTS } from './constants.js';
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const OAUTH_CLIENT_ID = '3b40164d28730e416cbd';
|
|
13
|
-
|
|
14
|
-
// Hard ceiling on how long to keep polling for the user to finish the
|
|
15
|
-
// device-code flow in the browser before we give up (10 minutes).
|
|
16
|
-
const OAUTH_DEVICE_CODE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
11
|
+
const OAUTH_CLIENT_ID = OAUTH.CLIENT_ID;
|
|
12
|
+
const OAUTH_DEVICE_CODE_TIMEOUT_MS = TIMEOUTS.OAUTH_DEVICE_CODE_MS;
|
|
17
13
|
|
|
18
14
|
/**
|
|
19
15
|
* Authenticate user
|
|
20
16
|
* @returns {Promise<void>}
|
|
21
17
|
*/
|
|
22
18
|
export async function authenticate() {
|
|
19
|
+
// When re-authenticating an existing profile, default the server prompt
|
|
20
|
+
// to the one already stored so `thinr login` is a couple of keystrokes.
|
|
21
|
+
const existing = readConfig();
|
|
22
|
+
|
|
23
23
|
// Ask for authentication method
|
|
24
24
|
const method = await select({
|
|
25
25
|
message: 'How would you like to authenticate?',
|
|
@@ -33,7 +33,7 @@ export async function authenticate() {
|
|
|
33
33
|
// Ask for server URL
|
|
34
34
|
const server = await input({
|
|
35
35
|
message: 'Enter server URL:',
|
|
36
|
-
default: 'console.thinr.io',
|
|
36
|
+
default: existing?.server || 'console.thinr.io',
|
|
37
37
|
validate: (input) => {
|
|
38
38
|
return input ? true : 'Server URL is required';
|
|
39
39
|
},
|
|
@@ -134,12 +134,29 @@ async function authenticateWithUserPass() {
|
|
|
134
134
|
* when the OAuth server rejects the refresh — both cases mean the
|
|
135
135
|
* caller has to re-authenticate.
|
|
136
136
|
*
|
|
137
|
+
* Concurrent callers share a single in-flight refresh: refresh tokens
|
|
138
|
+
* are rotated on use, so issuing two refreshes in parallel makes the
|
|
139
|
+
* second one fail against an already-consumed token. The shared
|
|
140
|
+
* promise lets every awaiter see the same result and lets the next
|
|
141
|
+
* caller (after completion) start a new refresh against the freshly
|
|
142
|
+
* persisted token.
|
|
143
|
+
*
|
|
137
144
|
* @returns {Promise<string>} The new access token.
|
|
138
145
|
*/
|
|
146
|
+
let inFlightRefresh = null;
|
|
147
|
+
|
|
139
148
|
export async function refreshToken() {
|
|
149
|
+
if (inFlightRefresh) return inFlightRefresh;
|
|
150
|
+
inFlightRefresh = doRefresh().finally(() => {
|
|
151
|
+
inFlightRefresh = null;
|
|
152
|
+
});
|
|
153
|
+
return inFlightRefresh;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function doRefresh() {
|
|
140
157
|
const config = readConfig();
|
|
141
158
|
if (!config.refresh_token) {
|
|
142
|
-
const err = new Error('
|
|
159
|
+
const err = new Error('Session expired. Run `thinr login` to sign in again.');
|
|
143
160
|
err.code = 'unauthorized';
|
|
144
161
|
throw err;
|
|
145
162
|
}
|
|
@@ -159,7 +176,7 @@ export async function refreshToken() {
|
|
|
159
176
|
_isRefresh: true,
|
|
160
177
|
});
|
|
161
178
|
} catch (_error) {
|
|
162
|
-
const err = new Error('
|
|
179
|
+
const err = new Error('Session expired. Run `thinr login` to sign in again.');
|
|
163
180
|
err.code = 'unauthorized';
|
|
164
181
|
throw err;
|
|
165
182
|
}
|
|
@@ -171,6 +188,37 @@ export async function refreshToken() {
|
|
|
171
188
|
return response.data.access_token;
|
|
172
189
|
}
|
|
173
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Milliseconds-since-epoch expiry of a JWT access token, or null when
|
|
193
|
+
* the token is not a decodable JWT (permanent API tokens have no exp).
|
|
194
|
+
*/
|
|
195
|
+
function jwtExpiryMs(token) {
|
|
196
|
+
try {
|
|
197
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
|
198
|
+
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Return a currently-valid access token, refreshing first when the
|
|
206
|
+
* stored one is an expired (or about-to-expire) JWT. HTTP requests
|
|
207
|
+
* recover from expiry through the api.js response interceptor, but
|
|
208
|
+
* WebSocket handshakes have no retry layer: callers opening a WS use
|
|
209
|
+
* this to start from a valid token. Tokens without a decodable `exp`
|
|
210
|
+
* and profiles without a refresh_token pass through untouched.
|
|
211
|
+
*/
|
|
212
|
+
export async function ensureFreshToken() {
|
|
213
|
+
const config = readConfig();
|
|
214
|
+
if (!config?.token || !config.refresh_token) return config?.token ?? null;
|
|
215
|
+
const expiresAt = jwtExpiryMs(config.token);
|
|
216
|
+
if (expiresAt === null || expiresAt - Date.now() > TIMEOUTS.TOKEN_EXPIRY_SLACK_MS) {
|
|
217
|
+
return config.token;
|
|
218
|
+
}
|
|
219
|
+
return refreshToken();
|
|
220
|
+
}
|
|
221
|
+
|
|
174
222
|
/**
|
|
175
223
|
* Delete user token from server
|
|
176
224
|
* @returns {Promise<void>}
|
|
@@ -179,7 +227,7 @@ export async function deleteToken() {
|
|
|
179
227
|
try {
|
|
180
228
|
const config = readConfig();
|
|
181
229
|
if (!config || !config.username || !config.server || !config.token) {
|
|
182
|
-
console.log(
|
|
230
|
+
console.log(warning('No configuration found'));
|
|
183
231
|
return;
|
|
184
232
|
}
|
|
185
233
|
|
|
@@ -193,11 +241,11 @@ export async function deleteToken() {
|
|
|
193
241
|
spinner.succeed('Token was already deleted');
|
|
194
242
|
} else {
|
|
195
243
|
spinner.fail('Failed to delete token');
|
|
196
|
-
console.error(
|
|
244
|
+
console.error(errorStyle(`Error: ${error.message}`));
|
|
197
245
|
}
|
|
198
246
|
}
|
|
199
247
|
} catch (error) {
|
|
200
|
-
console.error(
|
|
248
|
+
console.error(errorStyle(`Error: ${error.message}`));
|
|
201
249
|
}
|
|
202
250
|
}
|
|
203
251
|
|
|
@@ -258,9 +306,9 @@ async function authenticateWithOAuth() {
|
|
|
258
306
|
throw error;
|
|
259
307
|
}
|
|
260
308
|
|
|
261
|
-
console.log('\nYour one time device code is: ' +
|
|
309
|
+
console.log('\nYour one time device code is: ' + label(data.user_code));
|
|
262
310
|
console.log(
|
|
263
|
-
|
|
311
|
+
info('Press ' + label('ENTER')) +
|
|
264
312
|
' to open your browser or submit your device code here: ' +
|
|
265
313
|
data.verification_uri +
|
|
266
314
|
'\n',
|
|
@@ -342,9 +390,9 @@ async function authenticateWithOAuth() {
|
|
|
342
390
|
},
|
|
343
391
|
).catch((error) => {
|
|
344
392
|
if (error.cause === 'timeout') {
|
|
345
|
-
console.log(
|
|
393
|
+
console.log(warning('Polling aborted due to timeout'));
|
|
346
394
|
} else if (error.cause === 'validated') {
|
|
347
|
-
console.log(
|
|
395
|
+
console.log(success('Device authenticated successfully'));
|
|
348
396
|
}
|
|
349
397
|
// 'error' cause falls through; we surface pollError below.
|
|
350
398
|
});
|
|
@@ -362,7 +410,7 @@ async function authenticateWithOAuth() {
|
|
|
362
410
|
const username = decodedJWT.usr;
|
|
363
411
|
|
|
364
412
|
mergeConfig({ username, token, refresh_token });
|
|
365
|
-
console.log(
|
|
413
|
+
console.log(success('OAuth token validation successful\n'));
|
|
366
414
|
}
|
|
367
415
|
|
|
368
416
|
/**
|
|
@@ -372,7 +420,7 @@ async function authenticateWithOAuth() {
|
|
|
372
420
|
export function getAccessToken() {
|
|
373
421
|
const config = readConfig();
|
|
374
422
|
if (!config || !config.token) {
|
|
375
|
-
console.log(
|
|
423
|
+
console.log(warning('No token found in configuration'));
|
|
376
424
|
return null;
|
|
377
425
|
}
|
|
378
426
|
return config.token;
|
package/lib/bucket.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import api from './api.js';
|
|
2
|
+
import { apiError } from './errors.js';
|
|
3
|
+
import { requireConfig } from './config.js';
|
|
4
|
+
|
|
5
|
+
function resolveUser(user) {
|
|
6
|
+
return user || requireConfig().username;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Query an arbitrary time-series bucket declared in a product profile.
|
|
11
|
+
* When `device` is provided, scopes to that device's bucket; otherwise
|
|
12
|
+
* queries the user-level bucket across every device of the account.
|
|
13
|
+
*
|
|
14
|
+
* Buckets are the mechanism products use to persist structured samples
|
|
15
|
+
* over time — the platform ships a `monitoring` bucket by default, and
|
|
16
|
+
* users can declare custom ones for their own metrics.
|
|
17
|
+
*
|
|
18
|
+
* Time range:
|
|
19
|
+
* - `minutes`: shortcut for "last N minutes" (sets min_ts to
|
|
20
|
+
* `Date.now() - minutes * 60_000`, max_ts to 0).
|
|
21
|
+
* - `min_ts` / `max_ts`: explicit unix-millis range when `minutes`
|
|
22
|
+
* is not supplied; either may be omitted for an open bound.
|
|
23
|
+
*
|
|
24
|
+
* Aggregation: `agg` is the time-window size (e.g. "5m"), `agg_type`
|
|
25
|
+
* the reducer (e.g. "mean"), and `fields` a comma-separated allowlist
|
|
26
|
+
* to trim the response down.
|
|
27
|
+
*
|
|
28
|
+
* `group_by` is passed through verbatim — no default. Custom buckets
|
|
29
|
+
* may not even expose a `device` tag, so the caller decides whether
|
|
30
|
+
* to group (e.g. `group_by=device` for a fleet view) or read raw
|
|
31
|
+
* interleaved samples.
|
|
32
|
+
*
|
|
33
|
+
* @param {{
|
|
34
|
+
* bucket: string,
|
|
35
|
+
* device?: string | null,
|
|
36
|
+
* user?: string | null,
|
|
37
|
+
* items?: number,
|
|
38
|
+
* sort?: string,
|
|
39
|
+
* minutes?: number,
|
|
40
|
+
* min_ts?: number,
|
|
41
|
+
* max_ts?: number,
|
|
42
|
+
* agg?: string,
|
|
43
|
+
* agg_type?: string,
|
|
44
|
+
* fields?: string,
|
|
45
|
+
* group_by?: string | null,
|
|
46
|
+
* }} opts
|
|
47
|
+
*/
|
|
48
|
+
export async function getBucketData({
|
|
49
|
+
bucket,
|
|
50
|
+
device = null,
|
|
51
|
+
user = null,
|
|
52
|
+
items = 10,
|
|
53
|
+
sort = 'desc',
|
|
54
|
+
minutes,
|
|
55
|
+
min_ts,
|
|
56
|
+
max_ts,
|
|
57
|
+
agg,
|
|
58
|
+
agg_type,
|
|
59
|
+
fields,
|
|
60
|
+
group_by,
|
|
61
|
+
} = {}) {
|
|
62
|
+
if (!bucket) throw new Error('bucket is required');
|
|
63
|
+
const apiUser = resolveUser(user);
|
|
64
|
+
const params = { items, sort };
|
|
65
|
+
if (minutes) {
|
|
66
|
+
params.min_ts = Date.now() - minutes * 60 * 1000;
|
|
67
|
+
params.max_ts = 0;
|
|
68
|
+
} else {
|
|
69
|
+
if (min_ts) params.min_ts = min_ts;
|
|
70
|
+
if (max_ts !== undefined) params.max_ts = max_ts;
|
|
71
|
+
}
|
|
72
|
+
if (agg) params.agg = agg;
|
|
73
|
+
if (agg_type) params.agg_type = agg_type;
|
|
74
|
+
if (group_by) params.group_by = group_by;
|
|
75
|
+
if (fields) params.fields = fields;
|
|
76
|
+
|
|
77
|
+
const url = device
|
|
78
|
+
? `/v3/users/${apiUser}/devices/${device}/buckets/${bucket}/data`
|
|
79
|
+
: `/v2/users/${apiUser}/buckets/${bucket}/data`;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const res = await api.get(url, { params });
|
|
83
|
+
return res.data;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
throw apiError(e);
|
|
86
|
+
}
|
|
87
|
+
}
|