unitup 0.0.11 → 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/README.md +125 -6
- package/SECURITY.md +21 -0
- package/docs/index.html +118 -29
- package/index.d.ts +137 -0
- package/package.json +9 -1
- package/src/cli.js +318 -20
- package/src/index.js +53 -3
- package/src/schedule.js +462 -0
- package/src/systemd.js +126 -0
- package/src/unit.js +230 -4
- package/src/utils.js +253 -0
package/src/schedule.js
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import {
|
|
4
|
+
sanitizeServiceName,
|
|
5
|
+
getUnitFilename,
|
|
6
|
+
getTimerFilename,
|
|
7
|
+
resolveAbsolutePath,
|
|
8
|
+
validateDuration,
|
|
9
|
+
formatFutureTime,
|
|
10
|
+
formatRelativeTime,
|
|
11
|
+
saveScheduleMetadata,
|
|
12
|
+
readScheduleMetadata,
|
|
13
|
+
deleteScheduleMetadata,
|
|
14
|
+
getSchedulesDir
|
|
15
|
+
} from './utils.js';
|
|
16
|
+
import {
|
|
17
|
+
getUserUnitDir,
|
|
18
|
+
getUnitPath,
|
|
19
|
+
getTimerPath,
|
|
20
|
+
timerFileExists,
|
|
21
|
+
unitFileExists,
|
|
22
|
+
writeScheduleUnitFiles,
|
|
23
|
+
deleteScheduleUnitFiles
|
|
24
|
+
} from './unit.js';
|
|
25
|
+
import { runCommand } from './systemd.js';
|
|
26
|
+
import { detectRuntime, resolveRuntimeConfig } from './runtimes/index.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validates a systemd calendar expression using systemd-analyze if available.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} expression
|
|
32
|
+
* @returns {Promise<{ valid: boolean, error?: string, warning?: string }>}
|
|
33
|
+
*/
|
|
34
|
+
export async function validateCalendar(expression) {
|
|
35
|
+
if (!expression || typeof expression !== 'string' || !expression.trim()) {
|
|
36
|
+
throw new Error('Calendar expression cannot be empty.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const expr = expression.trim();
|
|
40
|
+
if (/[;&|$`\n\r]/.test(expr)) {
|
|
41
|
+
throw new Error(`Calendar expression contains invalid characters or shell injection attempt: "${expr}".`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const res = await runCommand('systemd-analyze', ['calendar', expr]);
|
|
46
|
+
if (res.code === 0) {
|
|
47
|
+
return { valid: true };
|
|
48
|
+
}
|
|
49
|
+
if (res.stderr && res.stderr.includes('Failed to parse calendar expression')) {
|
|
50
|
+
throw new Error(`Invalid calendar expression "${expr}": ${res.stderr.trim()}`);
|
|
51
|
+
}
|
|
52
|
+
// If systemd-analyze binary is missing
|
|
53
|
+
if (res.code === 127 || (res.stderr && res.stderr.includes('not found'))) {
|
|
54
|
+
return { valid: true, warning: 'systemd-analyze is not available; calendar expression could not be fully validated by systemd.' };
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err.message && err.message.startsWith('Invalid calendar expression')) {
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { valid: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates a systemd timer-based schedule.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} opts
|
|
69
|
+
* @returns {Promise<Object>}
|
|
70
|
+
*/
|
|
71
|
+
export async function createSchedule(opts = {}) {
|
|
72
|
+
if (opts.every && opts.calendar) {
|
|
73
|
+
throw new Error('Cannot use both --every and --calendar in the same schedule.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!opts.every && !opts.calendar && !opts.onBoot && !opts.onActive) {
|
|
77
|
+
throw new Error('At least one scheduling option (--every, --calendar, --on-boot, or --on-active) is required.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (opts.every) {
|
|
81
|
+
validateDuration(opts.every, '--every');
|
|
82
|
+
}
|
|
83
|
+
if (opts.onBoot) {
|
|
84
|
+
validateDuration(opts.onBoot, '--on-boot');
|
|
85
|
+
}
|
|
86
|
+
if (opts.onActive) {
|
|
87
|
+
validateDuration(opts.onActive, '--on-active');
|
|
88
|
+
}
|
|
89
|
+
if (opts.randomDelay) {
|
|
90
|
+
validateDuration(opts.randomDelay, '--random-delay');
|
|
91
|
+
}
|
|
92
|
+
if (opts.calendar) {
|
|
93
|
+
await validateCalendar(opts.calendar);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let rawName = opts.name;
|
|
97
|
+
if (!rawName) {
|
|
98
|
+
if (opts.script) {
|
|
99
|
+
rawName = path.basename(opts.script, path.extname(opts.script));
|
|
100
|
+
} else if (opts.command) {
|
|
101
|
+
rawName = path.basename(opts.command);
|
|
102
|
+
} else {
|
|
103
|
+
throw new Error('Schedule requires a name, script, or command.');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const safeName = sanitizeServiceName(rawName);
|
|
108
|
+
|
|
109
|
+
let commandExec = '';
|
|
110
|
+
let execArgs = [];
|
|
111
|
+
let detectedRuntime = opts.runtime || 'node';
|
|
112
|
+
let scriptPath = '';
|
|
113
|
+
|
|
114
|
+
if (opts.command) {
|
|
115
|
+
commandExec = resolveAbsolutePath(opts.command);
|
|
116
|
+
execArgs = Array.isArray(opts.args) ? opts.args : [];
|
|
117
|
+
if (opts.script) {
|
|
118
|
+
scriptPath = resolveAbsolutePath(opts.script);
|
|
119
|
+
}
|
|
120
|
+
} else if (opts.script) {
|
|
121
|
+
scriptPath = resolveAbsolutePath(opts.script);
|
|
122
|
+
const runtimeInfo = detectRuntime(scriptPath, opts.runtime);
|
|
123
|
+
detectedRuntime = runtimeInfo.runtime;
|
|
124
|
+
|
|
125
|
+
const resolved = await resolveRuntimeConfig({
|
|
126
|
+
runtime: detectedRuntime,
|
|
127
|
+
script: scriptPath,
|
|
128
|
+
command: opts.command,
|
|
129
|
+
args: opts.args || [],
|
|
130
|
+
runtimeArgs: opts.runtimeArgs || [],
|
|
131
|
+
customNodePath: opts.node
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
commandExec = resolved.command;
|
|
135
|
+
execArgs = resolved.args;
|
|
136
|
+
} else {
|
|
137
|
+
throw new Error('Either script or command must be provided to create schedule.');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const cwd = opts.cwd
|
|
141
|
+
? resolveAbsolutePath(opts.cwd)
|
|
142
|
+
: scriptPath
|
|
143
|
+
? path.dirname(scriptPath)
|
|
144
|
+
: path.dirname(commandExec);
|
|
145
|
+
|
|
146
|
+
const envObj = {};
|
|
147
|
+
if (opts.env) {
|
|
148
|
+
if (Array.isArray(opts.env)) {
|
|
149
|
+
for (const item of opts.env) {
|
|
150
|
+
const idx = item.indexOf('=');
|
|
151
|
+
if (idx > 0) {
|
|
152
|
+
const key = item.slice(0, idx).trim();
|
|
153
|
+
const val = item.slice(idx + 1).trim();
|
|
154
|
+
envObj[key] = val;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} else if (typeof opts.env === 'object') {
|
|
158
|
+
Object.assign(envObj, opts.env);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const scheduleOpts = {
|
|
163
|
+
name: safeName,
|
|
164
|
+
runtime: detectedRuntime,
|
|
165
|
+
command: commandExec,
|
|
166
|
+
args: execArgs,
|
|
167
|
+
script: scriptPath,
|
|
168
|
+
cwd,
|
|
169
|
+
env: envObj,
|
|
170
|
+
envFile: opts.envFile ? resolveAbsolutePath(opts.envFile) : undefined,
|
|
171
|
+
group: opts.group || 'default',
|
|
172
|
+
memoryHigh: opts.memoryHigh,
|
|
173
|
+
memoryMax: opts.memoryMax,
|
|
174
|
+
memorySwapMax: opts.memorySwapMax,
|
|
175
|
+
resources: opts.resources,
|
|
176
|
+
every: opts.every,
|
|
177
|
+
calendar: opts.calendar,
|
|
178
|
+
onBoot: opts.onBoot,
|
|
179
|
+
onActive: opts.onActive,
|
|
180
|
+
randomDelay: opts.randomDelay,
|
|
181
|
+
persistent: opts.persistent
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const unitFiles = writeScheduleUnitFiles(scheduleOpts);
|
|
185
|
+
|
|
186
|
+
if (opts.start || opts.enable) {
|
|
187
|
+
await runCommand('systemctl', ['--user', 'daemon-reload']);
|
|
188
|
+
await runCommand('systemctl', ['--user', 'enable', getTimerFilename(safeName)]);
|
|
189
|
+
await runCommand('systemctl', ['--user', 'start', getTimerFilename(safeName)]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
name: safeName,
|
|
194
|
+
group: opts.group || 'default',
|
|
195
|
+
type: 'timer',
|
|
196
|
+
runtime: detectedRuntime,
|
|
197
|
+
command: commandExec,
|
|
198
|
+
args: execArgs,
|
|
199
|
+
cwd,
|
|
200
|
+
schedule: {
|
|
201
|
+
every: opts.every || null,
|
|
202
|
+
calendar: opts.calendar || null,
|
|
203
|
+
onBoot: opts.onBoot || null,
|
|
204
|
+
onActive: opts.onActive || null,
|
|
205
|
+
persistent: Boolean(opts.persistent),
|
|
206
|
+
randomDelay: opts.randomDelay || null
|
|
207
|
+
},
|
|
208
|
+
serviceUnit: `unitup-${safeName}.service`,
|
|
209
|
+
timerUnit: `unitup-${safeName}.timer`,
|
|
210
|
+
servicePath: unitFiles.servicePath,
|
|
211
|
+
timerPath: unitFiles.timerPath
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Enables a timer schedule.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} name
|
|
219
|
+
* @returns {Promise<boolean>}
|
|
220
|
+
*/
|
|
221
|
+
export async function enableSchedule(name) {
|
|
222
|
+
const safeName = sanitizeServiceName(name);
|
|
223
|
+
if (!timerFileExists(safeName)) {
|
|
224
|
+
throw new Error(`Schedule timer unit up for "${name}" does not exist.`);
|
|
225
|
+
}
|
|
226
|
+
await runCommand('systemctl', ['--user', 'daemon-reload']);
|
|
227
|
+
const res = await runCommand('systemctl', ['--user', 'enable', '--now', getTimerFilename(safeName)]);
|
|
228
|
+
return res.code === 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Disables a timer schedule.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} name
|
|
235
|
+
* @returns {Promise<boolean>}
|
|
236
|
+
*/
|
|
237
|
+
export async function disableSchedule(name) {
|
|
238
|
+
const safeName = sanitizeServiceName(name);
|
|
239
|
+
if (!timerFileExists(safeName)) {
|
|
240
|
+
throw new Error(`Schedule timer unit up for "${name}" does not exist.`);
|
|
241
|
+
}
|
|
242
|
+
const res = await runCommand('systemctl', ['--user', 'disable', '--now', getTimerFilename(safeName)]);
|
|
243
|
+
return res.code === 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Manually runs a scheduled service unit without altering timer schedule.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} name
|
|
250
|
+
* @returns {Promise<boolean>}
|
|
251
|
+
*/
|
|
252
|
+
export async function runSchedule(name) {
|
|
253
|
+
const safeName = sanitizeServiceName(name);
|
|
254
|
+
if (!unitFileExists(safeName)) {
|
|
255
|
+
throw new Error(`Schedule service unit for "${name}" does not exist.`);
|
|
256
|
+
}
|
|
257
|
+
const res = await runCommand('systemctl', ['--user', 'start', getUnitFilename(safeName)]);
|
|
258
|
+
return res.code === 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Removes a timer schedule and its service/timer files.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} name
|
|
265
|
+
* @param {Object} [opts]
|
|
266
|
+
* @param {boolean} [opts.force]
|
|
267
|
+
* @returns {Promise<boolean>}
|
|
268
|
+
*/
|
|
269
|
+
export async function removeSchedule(name, opts = {}) {
|
|
270
|
+
const safeName = sanitizeServiceName(name);
|
|
271
|
+
const timerName = getTimerFilename(safeName);
|
|
272
|
+
const serviceName = getUnitFilename(safeName);
|
|
273
|
+
|
|
274
|
+
// 1. Disable timer unit
|
|
275
|
+
await runCommand('systemctl', ['--user', 'disable', '--now', timerName]);
|
|
276
|
+
|
|
277
|
+
// 2. Check if service is running
|
|
278
|
+
const activeRes = await runCommand('systemctl', ['--user', 'is-active', serviceName]);
|
|
279
|
+
const isRunning = activeRes.stdout.trim() === 'active';
|
|
280
|
+
|
|
281
|
+
if (isRunning) {
|
|
282
|
+
if (!opts.force) {
|
|
283
|
+
throw new Error(`Service ${serviceName} is currently running. Use --force to stop and remove.`);
|
|
284
|
+
}
|
|
285
|
+
await runCommand('systemctl', ['--user', 'stop', serviceName]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 3. Delete files
|
|
289
|
+
deleteScheduleUnitFiles(safeName);
|
|
290
|
+
|
|
291
|
+
// 4. Reload daemon & reset failed
|
|
292
|
+
await runCommand('systemctl', ['--user', 'daemon-reload']);
|
|
293
|
+
await runCommand('systemctl', ['--user', 'reset-failed']);
|
|
294
|
+
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Parses systemctl show properties output.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} stdout
|
|
302
|
+
* @returns {Record<string, string>}
|
|
303
|
+
*/
|
|
304
|
+
function parseSystemctlShow(stdout) {
|
|
305
|
+
const props = {};
|
|
306
|
+
if (!stdout) return props;
|
|
307
|
+
const lines = stdout.split('\n');
|
|
308
|
+
for (const line of lines) {
|
|
309
|
+
const idx = line.indexOf('=');
|
|
310
|
+
if (idx > 0) {
|
|
311
|
+
const k = line.slice(0, idx).trim();
|
|
312
|
+
const v = line.slice(idx + 1).trim();
|
|
313
|
+
props[k] = v;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return props;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Returns detailed status of a schedule.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} name
|
|
323
|
+
* @returns {Promise<Object>}
|
|
324
|
+
*/
|
|
325
|
+
export async function getScheduleStatus(name) {
|
|
326
|
+
const safeName = sanitizeServiceName(name);
|
|
327
|
+
const meta = readScheduleMetadata(safeName);
|
|
328
|
+
|
|
329
|
+
const timerFilename = getTimerFilename(safeName);
|
|
330
|
+
const serviceFilename = getUnitFilename(safeName);
|
|
331
|
+
|
|
332
|
+
const showTimerRes = await runCommand('systemctl', [
|
|
333
|
+
'--user',
|
|
334
|
+
'show',
|
|
335
|
+
timerFilename,
|
|
336
|
+
'--property=ActiveState,SubState,NextElapseUSecRealtime,NextElapseUSec,LastTriggerUSec,Unit,Triggers,UnitFileState'
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
const showServiceRes = await runCommand('systemctl', [
|
|
340
|
+
'--user',
|
|
341
|
+
'show',
|
|
342
|
+
serviceFilename,
|
|
343
|
+
'--property=ActiveState,SubState,ExecMainStatus,ExecMainCode'
|
|
344
|
+
]);
|
|
345
|
+
|
|
346
|
+
const timerProps = parseSystemctlShow(showTimerRes.stdout);
|
|
347
|
+
const serviceProps = parseSystemctlShow(showServiceRes.stdout);
|
|
348
|
+
|
|
349
|
+
// Determine schedule description string
|
|
350
|
+
let scheduleExpr = '';
|
|
351
|
+
if (meta?.schedule?.every) {
|
|
352
|
+
scheduleExpr = `every ${meta.schedule.every}`;
|
|
353
|
+
} else if (meta?.schedule?.calendar) {
|
|
354
|
+
scheduleExpr = meta.schedule.calendar;
|
|
355
|
+
} else if (meta?.schedule?.onBoot) {
|
|
356
|
+
scheduleExpr = `on-boot ${meta.schedule.onBoot}`;
|
|
357
|
+
} else if (meta?.schedule?.onActive) {
|
|
358
|
+
scheduleExpr = `on-active ${meta.schedule.onActive}`;
|
|
359
|
+
} else {
|
|
360
|
+
scheduleExpr = 'custom timer';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const activeState = timerProps.ActiveState || 'unknown';
|
|
364
|
+
const unitFileState = timerProps.UnitFileState || 'unknown';
|
|
365
|
+
|
|
366
|
+
let status = 'waiting';
|
|
367
|
+
if (serviceProps.ActiveState === 'active') {
|
|
368
|
+
status = 'running';
|
|
369
|
+
} else if (activeState === 'inactive' || unitFileState === 'disabled') {
|
|
370
|
+
status = 'disabled';
|
|
371
|
+
} else if (timerProps.SubState) {
|
|
372
|
+
status = timerProps.SubState;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Parse NextElapse timestamp
|
|
376
|
+
let rawNext = timerProps.NextElapseUSecRealtime || timerProps.NextElapseUSec;
|
|
377
|
+
if (!rawNext || rawNext === '0' || rawNext === '18446744073709551615') {
|
|
378
|
+
rawNext = 'n/a';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let nextRun = 'n/a';
|
|
382
|
+
if (rawNext !== 'n/a') {
|
|
383
|
+
const num = Number(rawNext);
|
|
384
|
+
if (!isNaN(num) && num > 0) {
|
|
385
|
+
nextRun = formatFutureTime(Math.floor(num / 1000));
|
|
386
|
+
} else {
|
|
387
|
+
nextRun = formatFutureTime(rawNext);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Parse LastTrigger timestamp
|
|
392
|
+
let rawLast = timerProps.LastTriggerUSec;
|
|
393
|
+
let lastRun = 'never';
|
|
394
|
+
if (rawLast && rawLast !== '0' && rawLast !== 'n/a') {
|
|
395
|
+
const num = Number(rawLast);
|
|
396
|
+
if (!isNaN(num) && num > 0) {
|
|
397
|
+
lastRun = formatRelativeTime(Math.floor(num / 1000));
|
|
398
|
+
} else {
|
|
399
|
+
lastRun = formatRelativeTime(rawLast);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
name: safeName,
|
|
405
|
+
group: meta?.group || 'default',
|
|
406
|
+
schedule: scheduleExpr,
|
|
407
|
+
nextRun,
|
|
408
|
+
lastRun,
|
|
409
|
+
status,
|
|
410
|
+
activeState,
|
|
411
|
+
subState: timerProps.SubState || 'unknown',
|
|
412
|
+
unitFileState,
|
|
413
|
+
serviceActiveState: serviceProps.ActiveState || 'unknown',
|
|
414
|
+
metadata: meta,
|
|
415
|
+
serviceUnit: serviceFilename,
|
|
416
|
+
timerUnit: timerFilename
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Lists all schedules and their status.
|
|
422
|
+
*
|
|
423
|
+
* @param {string} [group]
|
|
424
|
+
* @returns {Promise<Array<Object>>}
|
|
425
|
+
*/
|
|
426
|
+
export async function listSchedules(group) {
|
|
427
|
+
const dir = getSchedulesDir();
|
|
428
|
+
const scheduleNames = new Set();
|
|
429
|
+
|
|
430
|
+
if (fs.existsSync(dir)) {
|
|
431
|
+
const files = fs.readdirSync(dir);
|
|
432
|
+
for (const f of files) {
|
|
433
|
+
if (f.endsWith('.json')) {
|
|
434
|
+
scheduleNames.add(f.slice(0, -5));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const unitDir = getUserUnitDir();
|
|
440
|
+
if (fs.existsSync(unitDir)) {
|
|
441
|
+
const files = fs.readdirSync(unitDir);
|
|
442
|
+
for (const f of files) {
|
|
443
|
+
if (f.startsWith('unitup-') && f.endsWith('.timer')) {
|
|
444
|
+
scheduleNames.add(f.slice(7, -6));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const result = [];
|
|
450
|
+
for (const name of scheduleNames) {
|
|
451
|
+
try {
|
|
452
|
+
const st = await getScheduleStatus(name);
|
|
453
|
+
if (!group || st.group === group) {
|
|
454
|
+
result.push(st);
|
|
455
|
+
}
|
|
456
|
+
} catch {
|
|
457
|
+
// ignore individual parse errors
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return result;
|
|
462
|
+
}
|
package/src/systemd.js
CHANGED
|
@@ -904,3 +904,129 @@ export async function executeJournalctlMaintenance(action, opts = {}) {
|
|
|
904
904
|
|
|
905
905
|
return (res.stdout || res.stderr || 'Journal maintenance completed successfully.').trim();
|
|
906
906
|
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Retrieves memory usage overview for a single service or schedule task.
|
|
910
|
+
*
|
|
911
|
+
* @param {string} name
|
|
912
|
+
* @returns {Promise<Object>}
|
|
913
|
+
*/
|
|
914
|
+
export async function getServiceMemoryUsage(name) {
|
|
915
|
+
const safeName = sanitizeServiceName(name);
|
|
916
|
+
const unitFilename = getUnitFilename(safeName);
|
|
917
|
+
|
|
918
|
+
const res = await runCommand('systemctl', [
|
|
919
|
+
'--user',
|
|
920
|
+
'show',
|
|
921
|
+
unitFilename,
|
|
922
|
+
'--property=ActiveState,SubState,MainPID,MemoryCurrent,MemoryPeak,MemoryHigh,MemoryMax,MemorySwapMax'
|
|
923
|
+
]);
|
|
924
|
+
|
|
925
|
+
const props = {};
|
|
926
|
+
if (res.stdout) {
|
|
927
|
+
for (const line of res.stdout.split('\n')) {
|
|
928
|
+
const idx = line.indexOf('=');
|
|
929
|
+
if (idx > 0) {
|
|
930
|
+
props[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const { readScheduleMetadata } = await import('./utils.js');
|
|
936
|
+
const meta = readAppMetadata(safeName) || readScheduleMetadata(safeName);
|
|
937
|
+
const { formatMemoryBytes, resolveEffectiveMemoryLimits } = await import('./utils.js');
|
|
938
|
+
|
|
939
|
+
const effective = resolveEffectiveMemoryLimits(meta || {});
|
|
940
|
+
|
|
941
|
+
const memoryCurrentBytes = Number(props.MemoryCurrent);
|
|
942
|
+
const rawBytes = !isNaN(memoryCurrentBytes) && memoryCurrentBytes > 0 && memoryCurrentBytes < 1e15 ? memoryCurrentBytes : 0;
|
|
943
|
+
|
|
944
|
+
const status = props.ActiveState === 'active' ? (props.SubState === 'running' ? 'running' : props.SubState || 'active') : (props.ActiveState || 'stopped');
|
|
945
|
+
|
|
946
|
+
return {
|
|
947
|
+
name: safeName,
|
|
948
|
+
group: meta?.group || 'default',
|
|
949
|
+
type: meta?.type === 'timer' ? 'timer' : 'service',
|
|
950
|
+
status,
|
|
951
|
+
pid: props.MainPID && props.MainPID !== '0' ? props.MainPID : '-',
|
|
952
|
+
memoryBytes: rawBytes,
|
|
953
|
+
memory: formatMemoryBytes(props.MemoryCurrent),
|
|
954
|
+
memoryPeak: formatMemoryBytes(props.MemoryPeak),
|
|
955
|
+
memoryHigh: formatMemoryBytes(props.MemoryHigh || effective.memoryHigh),
|
|
956
|
+
memoryMax: formatMemoryBytes(props.MemoryMax || effective.memoryMax),
|
|
957
|
+
memorySwapMax: formatMemoryBytes(props.MemorySwapMax || effective.memorySwapMax)
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Retrieves aggregated memory usage overview for all unitup services and schedules.
|
|
963
|
+
*
|
|
964
|
+
* @param {Object} [opts]
|
|
965
|
+
* @param {string} [opts.group]
|
|
966
|
+
* @returns {Promise<Object>}
|
|
967
|
+
*/
|
|
968
|
+
export async function getAllServicesMemoryUsage(opts = {}) {
|
|
969
|
+
const { formatMemoryBytes } = await import('./utils.js');
|
|
970
|
+
const services = await listServices({ group: opts.group });
|
|
971
|
+
const { listSchedules } = await import('./schedule.js');
|
|
972
|
+
const schedules = await listSchedules(opts.group);
|
|
973
|
+
|
|
974
|
+
const items = [];
|
|
975
|
+
let totalBytes = 0;
|
|
976
|
+
const processedNames = new Set();
|
|
977
|
+
|
|
978
|
+
for (const svc of services) {
|
|
979
|
+
processedNames.add(svc.name);
|
|
980
|
+
try {
|
|
981
|
+
const mem = await getServiceMemoryUsage(svc.name);
|
|
982
|
+
items.push({
|
|
983
|
+
name: svc.name,
|
|
984
|
+
group: svc.group || 'default',
|
|
985
|
+
type: 'service',
|
|
986
|
+
status: svc.status,
|
|
987
|
+
pid: svc.pid,
|
|
988
|
+
memory: mem.memory,
|
|
989
|
+
memoryPeak: mem.memoryPeak,
|
|
990
|
+
memoryMax: mem.memoryMax,
|
|
991
|
+
memoryHigh: mem.memoryHigh,
|
|
992
|
+
memoryBytes: mem.memoryBytes
|
|
993
|
+
});
|
|
994
|
+
totalBytes += mem.memoryBytes;
|
|
995
|
+
} catch {
|
|
996
|
+
// ignore
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
for (const sched of schedules) {
|
|
1001
|
+
if (!processedNames.has(sched.name)) {
|
|
1002
|
+
processedNames.add(sched.name);
|
|
1003
|
+
try {
|
|
1004
|
+
const mem = await getServiceMemoryUsage(sched.name);
|
|
1005
|
+
items.push({
|
|
1006
|
+
name: sched.name,
|
|
1007
|
+
group: sched.group || 'default',
|
|
1008
|
+
type: 'timer',
|
|
1009
|
+
status: sched.status,
|
|
1010
|
+
pid: mem.pid,
|
|
1011
|
+
memory: mem.memory,
|
|
1012
|
+
memoryPeak: mem.memoryPeak,
|
|
1013
|
+
memoryMax: mem.memoryMax,
|
|
1014
|
+
memoryHigh: mem.memoryHigh,
|
|
1015
|
+
memoryBytes: mem.memoryBytes
|
|
1016
|
+
});
|
|
1017
|
+
totalBytes += mem.memoryBytes;
|
|
1018
|
+
} catch {
|
|
1019
|
+
// ignore
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const runningCount = items.filter(i => i.status === 'running' || i.status === 'active').length;
|
|
1025
|
+
|
|
1026
|
+
return {
|
|
1027
|
+
items,
|
|
1028
|
+
totalBytes,
|
|
1029
|
+
totalMemory: formatMemoryBytes(totalBytes),
|
|
1030
|
+
runningCount
|
|
1031
|
+
};
|
|
1032
|
+
}
|