Skip to content

Commit c8bd276

Browse files
committed
adding start/stop button
1 parent e849326 commit c8bd276

6 files changed

Lines changed: 362 additions & 224 deletions

File tree

lib/service/pm2Service.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import config from '../config.js';
1414
const pm2Connect = promisify(pm2.connect.bind(pm2));
1515
const pm2List = promisify(pm2.list.bind(pm2));
1616
const pm2Restart = promisify(pm2.restart.bind(pm2));
17+
const pm2Stop = promisify(pm2.stop.bind(pm2));
1718
const pm2Trigger = promisify(pm2.trigger.bind(pm2));
1819
const pm2Delete = promisify(pm2.delete.bind(pm2));
1920

@@ -306,6 +307,44 @@ export async function restartProcess(processId) {
306307
}
307308
}
308309

310+
/**
311+
* Stop a PM2 process by id without removing it from PM2.
312+
*
313+
* The process descriptor is kept in PM2's list with a `stopped` status so it
314+
* can be started again later.
315+
*
316+
* @param {string|number} processId
317+
* @returns {Promise<void>}
318+
*/
319+
export async function stopProcess(processId) {
320+
await ensureConnected();
321+
try {
322+
await pm2Stop(String(processId));
323+
} catch (err) {
324+
resetConnection();
325+
throw err;
326+
}
327+
}
328+
329+
/**
330+
* Start an existing (stopped) PM2 process by name or id.
331+
*
332+
* Unlike {@link startProcess}, which launches a brand-new process from a
333+
* configuration object, this resumes a process that PM2 already knows about.
334+
*
335+
* @param {string|number} processId
336+
* @returns {Promise<void>}
337+
*/
338+
export async function startStoppedProcess(processId) {
339+
await ensureConnected();
340+
try {
341+
await pm2Start(String(processId));
342+
} catch (err) {
343+
resetConnection();
344+
throw err;
345+
}
346+
}
347+
309348
/**
310349
* Start a new PM2 process from a configuration object.
311350
*

lib/transport/router.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,44 @@ router.post('/api/processes/:id/restart', requireAuth, validateProcessId, async
340340
}
341341
});
342342

343+
router.post('/api/processes/:id/stop', requireAuth, validateProcessId, async (req, res) => {
344+
try {
345+
if (!consumeCsrfToken(req, req.session)) {
346+
logger.warn(
347+
`[SECURITY] CSRF token mismatch for user: ${req.session.username} during stop of process ${req.params.id} from identity: ${getClientIdentity(req)}`,
348+
);
349+
return res.status(403).json({ error: 'CSRF token mismatch' });
350+
}
351+
logger.info(
352+
`[ACTION] User: ${req.session.username} is stopping process: ${req.params.id} from identity: ${getClientIdentity(req)}`,
353+
);
354+
await pm2.stopProcess(req.params.id);
355+
const details = await pm2.loadProcessDetails(req.params.id);
356+
res.json({ ok: true, processId: req.params.id, details });
357+
} catch (error) {
358+
res.status(500).json({ error: error.message });
359+
}
360+
});
361+
362+
router.post('/api/processes/:id/start', requireAuth, validateProcessId, async (req, res) => {
363+
try {
364+
if (!consumeCsrfToken(req, req.session)) {
365+
logger.warn(
366+
`[SECURITY] CSRF token mismatch for user: ${req.session.username} during start of process ${req.params.id} from identity: ${getClientIdentity(req)}`,
367+
);
368+
return res.status(403).json({ error: 'CSRF token mismatch' });
369+
}
370+
logger.info(
371+
`[ACTION] User: ${req.session.username} is starting process: ${req.params.id} from identity: ${getClientIdentity(req)}`,
372+
);
373+
await pm2.startStoppedProcess(req.params.id);
374+
const details = await pm2.loadProcessDetails(req.params.id);
375+
res.json({ ok: true, processId: req.params.id, details });
376+
} catch (error) {
377+
res.status(500).json({ error: error.message });
378+
}
379+
});
380+
343381
/**
344382
* Delete a process from PM2 and optionally purge its deployment data.
345383
*

package.json

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pm2-hawkeye",
3-
"version": "5.1.1",
3+
"version": "5.2.0",
44
"description": "Modern web dashboard for PM2 processes, metrics, and log files",
55
"type": "module",
66
"main": "lib/transport/server.js",
@@ -32,31 +32,31 @@
3232
"npm": ">=7.0.0"
3333
},
3434
"dependencies": {
35-
"better-sqlite3": "^12.10.0",
35+
"better-sqlite3": "^12.11.1",
3636
"cookie-parser": "^1.4.7",
3737
"croner": "^10.0.1",
3838
"dotenv": "^17.4.2",
3939
"express": "^5.2.1",
4040
"pm2": "^6.0.14",
41-
"ws": "^8.20.1"
41+
"ws": "^8.21.1"
4242
},
4343
"devDependencies": {
4444
"@eslint/js": "^10.0.1",
4545
"chai": "^6.2.2",
4646
"chalk": "^5.6.2",
47-
"esbuild": "^0.28.0",
48-
"less": "^4.6.4",
49-
"react": "^19.2.6",
50-
"react-dom": "^19.2.6",
47+
"esbuild": "^0.28.1",
48+
"less": "^4.6.7",
49+
"react": "^19.2.7",
50+
"react-dom": "^19.2.7",
5151
"react-markdown": "^10.1.0",
52-
"eslint": "^10.4.0",
52+
"eslint": "^10.7.0",
5353
"eslint-config-prettier": "^10.1.8",
5454
"eslint-plugin-react": "^7.37.5",
55-
"globals": "^17.6.0",
55+
"globals": "^17.7.0",
5656
"mocha": "^11.7.6",
5757
"node-fetch": "^3.3.2",
5858
"nodemon": "^3.1.14",
59-
"prettier": "^3.8.3",
59+
"prettier": "^3.9.5",
6060
"supertest": "^7.2.2"
6161
}
6262
}

src/components/App.jsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,44 @@ export default function App() {
496496
}
497497
};
498498

499+
/**
500+
* Stop the selected PM2 process without removing it from PM2.
501+
* The process stays in the list with a `stopped` status so it can be
502+
* started again via {@link onStart}.
503+
*/
504+
const onStop = async () => {
505+
if (selectedProcessId === null || selectedProcessId === undefined || !csrfToken) {
506+
return;
507+
}
508+
try {
509+
await fetchJson(`/api/processes/${encodeURIComponent(selectedProcessId)}/stop`, {
510+
method: 'POST',
511+
headers: { 'X-CSRF-Token': csrfToken },
512+
});
513+
await refreshCsrf();
514+
} catch (stopError) {
515+
setError(stopError.message);
516+
}
517+
};
518+
519+
/**
520+
* Start the selected (stopped) PM2 process.
521+
*/
522+
const onStart = async () => {
523+
if (selectedProcessId === null || selectedProcessId === undefined || !csrfToken) {
524+
return;
525+
}
526+
try {
527+
await fetchJson(`/api/processes/${encodeURIComponent(selectedProcessId)}/start`, {
528+
method: 'POST',
529+
headers: { 'X-CSRF-Token': csrfToken },
530+
});
531+
await refreshCsrf();
532+
} catch (startError) {
533+
setError(startError.message);
534+
}
535+
};
536+
499537
/**
500538
* Delete the selected process from PM2.
501539
* When `withDeploy` is true the deployment record and its on-disk directory
@@ -681,6 +719,8 @@ export default function App() {
681719
sseConnected={wsConnected}
682720
onLogout={onLogout}
683721
onRestart={onRestart}
722+
onStop={onStop}
723+
onStart={onStart}
684724
onDelete={onDelete}
685725
onRemoveOrphan={onRemoveOrphan}
686726
selectedDeployment={selectedDeployment}

src/components/HeroCard.jsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import Actions from './Actions.jsx';
1717
* selectedProcess: object | null,
1818
* details: object | null,
1919
* onRestart: () => Promise<void>,
20+
* onStop: () => Promise<void>,
21+
* onStart: () => Promise<void>,
2022
* onDelete: (withDeploy?: boolean) => Promise<void>,
2123
* onRemoveOrphan: (pm2Name: string) => Promise<void>,
2224
* selectedDeployment: object | null,
@@ -31,6 +33,8 @@ export default function HeroCard({
3133
selectedProcess,
3234
details,
3335
onRestart,
36+
onStop,
37+
onStart,
3438
onDelete,
3539
onRemoveOrphan,
3640
selectedDeployment,
@@ -41,6 +45,7 @@ export default function HeroCard({
4145
onCsrfRefresh,
4246
}) {
4347
const [confirmingRestart, setConfirmingRestart] = useState(false);
48+
const [confirmingStop, setConfirmingStop] = useState(false);
4449
const [confirmingDelete, setConfirmingDelete] = useState(false);
4550
const [confirmingRemoveOrphan, setConfirmingRemoveOrphan] = useState(false);
4651

@@ -94,6 +99,35 @@ export default function HeroCard({
9499
Restart
95100
</button>
96101
)}
102+
{!isOrphan && (
103+
statusLower === 'online' ? (
104+
confirmingStop ? (
105+
<span className="hero-confirm">
106+
Stop?
107+
<button className="hero-confirm-btn hero-confirm-btn--yes" onClick={async () => { setConfirmingStop(false); await onStop(); }}>Yes</button>
108+
<button className="hero-confirm-btn" onClick={() => setConfirmingStop(false)}>No</button>
109+
</span>
110+
) : (
111+
<button
112+
className="ghost-button"
113+
type="button"
114+
disabled={!selectedProcess}
115+
onClick={() => setConfirmingStop(true)}
116+
>
117+
Stop
118+
</button>
119+
)
120+
) : (
121+
<button
122+
className="ghost-button"
123+
type="button"
124+
disabled={!selectedProcess}
125+
onClick={async () => { await onStart(); }}
126+
>
127+
Start
128+
</button>
129+
)
130+
)}
97131
{selectedDeployment && !isOrphan && (
98132
<button
99133
className="ghost-button"

0 commit comments

Comments
 (0)