bgrun 3.7.1 → 3.9.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.
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/deploy/:name — Git pull + install deps + restart a process
|
|
3
|
+
*
|
|
4
|
+
* Only works if the process directory is a git repository.
|
|
5
|
+
* Steps: git pull → bun install → force restart
|
|
6
|
+
*/
|
|
7
|
+
import { getProcess } from '../../../../../src/db';
|
|
8
|
+
import { handleRun } from '../../../../../src/commands/run';
|
|
9
|
+
import { measure } from 'measure-fn';
|
|
10
|
+
import { $ } from 'bun';
|
|
11
|
+
|
|
12
|
+
export async function POST(req: Request, { params }: { params: { name: string } }) {
|
|
13
|
+
const name = decodeURIComponent(params.name);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const proc = getProcess(name);
|
|
17
|
+
if (!proc) {
|
|
18
|
+
return Response.json({ error: `Process '${name}' not found` }, { status: 404 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const dir = proc.workdir;
|
|
22
|
+
|
|
23
|
+
// Check if it's a git repo
|
|
24
|
+
const isGit = await Bun.file(`${dir}/.git/HEAD`).exists();
|
|
25
|
+
if (!isGit) {
|
|
26
|
+
return Response.json({ error: `'${dir}' is not a git repository` }, { status: 400 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const result = await measure(`Deploy "${name}"`, async () => {
|
|
30
|
+
// 1. Git pull
|
|
31
|
+
$.cwd(dir);
|
|
32
|
+
const pullOutput = await $`git pull`.text();
|
|
33
|
+
|
|
34
|
+
// 2. Install dependencies (detect package manager)
|
|
35
|
+
let installOutput = '';
|
|
36
|
+
const hasBunLock = await Bun.file(`${dir}/bun.lock`).exists() || await Bun.file(`${dir}/bun.lockb`).exists();
|
|
37
|
+
const hasPackageJson = await Bun.file(`${dir}/package.json`).exists();
|
|
38
|
+
|
|
39
|
+
if (hasPackageJson) {
|
|
40
|
+
installOutput = await $`bun install`.text();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 3. Restart the process
|
|
44
|
+
await handleRun({
|
|
45
|
+
action: 'run',
|
|
46
|
+
name,
|
|
47
|
+
force: true,
|
|
48
|
+
remoteName: '',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return { pullOutput: pullOutput.trim(), installOutput: installOutput.trim() };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return Response.json({ success: true, ...result });
|
|
55
|
+
} catch (e: any) {
|
|
56
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -983,6 +983,125 @@ tr.animate-in:nth-child(10) {
|
|
|
983
983
|
border-color: var(--warning-border);
|
|
984
984
|
}
|
|
985
985
|
|
|
986
|
+
.action-btn.deploy:hover {
|
|
987
|
+
background: rgba(20, 184, 166, 0.1);
|
|
988
|
+
color: #14b8a6;
|
|
989
|
+
border-color: rgba(20, 184, 166, 0.3);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/* ─── Context Menu ─── */
|
|
993
|
+
.context-menu {
|
|
994
|
+
position: fixed;
|
|
995
|
+
z-index: 10000;
|
|
996
|
+
min-width: 180px;
|
|
997
|
+
background: rgba(22, 27, 34, 0.95);
|
|
998
|
+
backdrop-filter: blur(16px);
|
|
999
|
+
border: 1px solid var(--border);
|
|
1000
|
+
border-radius: 10px;
|
|
1001
|
+
padding: 4px;
|
|
1002
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
|
1003
|
+
animation: contextIn 0.15s ease-out;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.context-menu.closing {
|
|
1007
|
+
animation: contextOut 0.12s ease-in forwards;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
@keyframes contextIn {
|
|
1011
|
+
from {
|
|
1012
|
+
opacity: 0;
|
|
1013
|
+
transform: scale(0.95) translateY(-4px);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
to {
|
|
1017
|
+
opacity: 1;
|
|
1018
|
+
transform: scale(1) translateY(0);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
@keyframes contextOut {
|
|
1023
|
+
from {
|
|
1024
|
+
opacity: 1;
|
|
1025
|
+
transform: scale(1) translateY(0);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
to {
|
|
1029
|
+
opacity: 0;
|
|
1030
|
+
transform: scale(0.95) translateY(-4px);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
.context-item {
|
|
1035
|
+
display: flex;
|
|
1036
|
+
align-items: center;
|
|
1037
|
+
gap: 10px;
|
|
1038
|
+
width: 100%;
|
|
1039
|
+
padding: 8px 12px;
|
|
1040
|
+
background: none;
|
|
1041
|
+
border: none;
|
|
1042
|
+
border-radius: 6px;
|
|
1043
|
+
color: var(--text-secondary);
|
|
1044
|
+
font-size: 0.8125rem;
|
|
1045
|
+
cursor: pointer;
|
|
1046
|
+
transition: all 0.12s ease;
|
|
1047
|
+
text-align: left;
|
|
1048
|
+
font-family: inherit;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
.context-item svg {
|
|
1052
|
+
width: 15px;
|
|
1053
|
+
height: 15px;
|
|
1054
|
+
flex-shrink: 0;
|
|
1055
|
+
opacity: 0.7;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
.context-item:hover {
|
|
1059
|
+
background: rgba(255, 255, 255, 0.06);
|
|
1060
|
+
color: var(--text-primary);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.context-item:hover svg {
|
|
1064
|
+
opacity: 1;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
.context-item.danger:hover {
|
|
1068
|
+
background: var(--danger-bg);
|
|
1069
|
+
color: var(--danger);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
.context-item.danger:hover svg {
|
|
1073
|
+
color: var(--danger);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
.context-item.success:hover {
|
|
1077
|
+
background: var(--success-bg);
|
|
1078
|
+
color: var(--success);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
.context-item.success:hover svg {
|
|
1082
|
+
color: var(--success);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
.context-item.deploy:hover {
|
|
1086
|
+
background: rgba(20, 184, 166, 0.1);
|
|
1087
|
+
color: #14b8a6;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
.context-item.deploy:hover svg {
|
|
1091
|
+
color: #14b8a6;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
.context-divider {
|
|
1095
|
+
height: 1px;
|
|
1096
|
+
background: var(--border);
|
|
1097
|
+
margin: 4px 8px;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/* Hint that rows are right-clickable */
|
|
1101
|
+
#processes-table tr[data-process-name] {
|
|
1102
|
+
cursor: default;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
986
1105
|
/* ─── Empty State ─── */
|
|
987
1106
|
.empty-state {
|
|
988
1107
|
text-align: center;
|
|
@@ -83,6 +83,17 @@ function RestartIcon() {
|
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
function DeployIcon() {
|
|
87
|
+
return (
|
|
88
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
89
|
+
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
|
|
90
|
+
<path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
|
|
91
|
+
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
|
|
92
|
+
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
|
|
93
|
+
</svg>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
86
97
|
// ─── Utility: Format Runtime ───
|
|
87
98
|
|
|
88
99
|
function formatRuntime(raw: string): string {
|
|
@@ -165,25 +176,6 @@ function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
|
|
|
165
176
|
</td>
|
|
166
177
|
<td className="command" title={p.command}>{p.command}</td>
|
|
167
178
|
<td className="runtime">{formatRuntime(p.runtime)}</td>
|
|
168
|
-
<td className="actions">
|
|
169
|
-
<button className="action-btn info" data-action="logs" data-name={p.name} title="View Logs">
|
|
170
|
-
<LogsIcon />
|
|
171
|
-
</button>
|
|
172
|
-
{p.running
|
|
173
|
-
? <button className="action-btn danger" data-action="stop" data-name={p.name} title="Stop">
|
|
174
|
-
<StopIcon />
|
|
175
|
-
</button>
|
|
176
|
-
: <button className="action-btn success" data-action="restart" data-name={p.name} title="Start">
|
|
177
|
-
<PlayIcon />
|
|
178
|
-
</button>
|
|
179
|
-
}
|
|
180
|
-
<button className="action-btn warning" data-action="restart" data-name={p.name} title="Restart">
|
|
181
|
-
<RestartIcon />
|
|
182
|
-
</button>
|
|
183
|
-
<button className="action-btn danger" data-action="delete" data-name={p.name} title="Delete">
|
|
184
|
-
<TrashIcon />
|
|
185
|
-
</button>
|
|
186
|
-
</td>
|
|
187
179
|
</tr>
|
|
188
180
|
);
|
|
189
181
|
}
|
|
@@ -256,6 +248,9 @@ function ProcessCard({ p }: { p: ProcessData }) {
|
|
|
256
248
|
<button className="action-btn warning" data-action="restart" data-name={p.name} title="Restart">
|
|
257
249
|
<RestartIcon /> Restart
|
|
258
250
|
</button>
|
|
251
|
+
<button className="action-btn deploy" data-action="deploy" data-name={p.name} title="Deploy (git pull + restart)">
|
|
252
|
+
<DeployIcon /> Deploy
|
|
253
|
+
</button>
|
|
259
254
|
<button className="action-btn danger" data-action="delete" data-name={p.name} title="Delete">
|
|
260
255
|
<TrashIcon /> Delete
|
|
261
256
|
</button>
|
|
@@ -595,6 +590,24 @@ export default function mount(): () => void {
|
|
|
595
590
|
break;
|
|
596
591
|
}
|
|
597
592
|
|
|
593
|
+
case 'deploy': {
|
|
594
|
+
showToast(`Deploying "${name}"...`, 'info');
|
|
595
|
+
try {
|
|
596
|
+
const res = await fetch(`/api/deploy/${encodeURIComponent(name)}`, { method: 'POST' });
|
|
597
|
+
const data = await res.json();
|
|
598
|
+
if (res.ok) {
|
|
599
|
+
showToast(`Deployed "${name}" successfully`, 'success');
|
|
600
|
+
} else {
|
|
601
|
+
showToast(data.error || `Failed to deploy "${name}"`, 'error');
|
|
602
|
+
}
|
|
603
|
+
} catch {
|
|
604
|
+
showToast(`Failed to deploy "${name}"`, 'error');
|
|
605
|
+
}
|
|
606
|
+
await loadProcessesFresh();
|
|
607
|
+
mutationUntil = Date.now() + 5000;
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
|
|
598
611
|
case 'logs':
|
|
599
612
|
openDrawer(name);
|
|
600
613
|
break;
|
|
@@ -603,20 +616,99 @@ export default function mount(): () => void {
|
|
|
603
616
|
|
|
604
617
|
const tbody = $('processes-table');
|
|
605
618
|
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
619
|
+
// ─── Context Menu ───
|
|
620
|
+
let contextMenuEl: HTMLElement | null = null;
|
|
621
|
+
|
|
622
|
+
function closeContextMenu() {
|
|
623
|
+
if (contextMenuEl) {
|
|
624
|
+
contextMenuEl.classList.add('closing');
|
|
625
|
+
setTimeout(() => { contextMenuEl?.remove(); contextMenuEl = null; }, 150);
|
|
612
626
|
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function showContextMenu(name: string, x: number, y: number) {
|
|
630
|
+
closeContextMenu();
|
|
631
|
+
const proc = allProcesses.find(p => p.name === name);
|
|
632
|
+
if (!proc) return;
|
|
633
|
+
|
|
634
|
+
const menu = (
|
|
635
|
+
<div className="context-menu" style={{ left: `${x}px`, top: `${y}px` }}>
|
|
636
|
+
<button className="context-item" data-action="logs" data-name={name}>
|
|
637
|
+
<LogsIcon /> View Logs
|
|
638
|
+
</button>
|
|
639
|
+
<div className="context-divider"></div>
|
|
640
|
+
{proc.running
|
|
641
|
+
? <button className="context-item danger" data-action="stop" data-name={name}>
|
|
642
|
+
<StopIcon /> Stop
|
|
643
|
+
</button>
|
|
644
|
+
: <button className="context-item success" data-action="restart" data-name={name}>
|
|
645
|
+
<PlayIcon /> Start
|
|
646
|
+
</button>
|
|
647
|
+
}
|
|
648
|
+
<button className="context-item" data-action="restart" data-name={name}>
|
|
649
|
+
<RestartIcon /> Restart
|
|
650
|
+
</button>
|
|
651
|
+
<button className="context-item deploy" data-action="deploy" data-name={name}>
|
|
652
|
+
<DeployIcon /> Deploy
|
|
653
|
+
</button>
|
|
654
|
+
<div className="context-divider"></div>
|
|
655
|
+
<button className="context-item danger" data-action="delete" data-name={name}>
|
|
656
|
+
<TrashIcon /> Delete
|
|
657
|
+
</button>
|
|
658
|
+
</div>
|
|
659
|
+
) as unknown as HTMLElement;
|
|
660
|
+
|
|
661
|
+
// Handle clicks inside the menu
|
|
662
|
+
menu.addEventListener('click', (e: Event) => {
|
|
663
|
+
const item = (e.target as Element).closest('[data-action]');
|
|
664
|
+
if (item) {
|
|
665
|
+
handleAction(e);
|
|
666
|
+
closeContextMenu();
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
document.body.appendChild(menu);
|
|
671
|
+
contextMenuEl = menu;
|
|
672
|
+
|
|
673
|
+
// Adjust position if menu goes off-screen
|
|
674
|
+
const rect = menu.getBoundingClientRect();
|
|
675
|
+
if (rect.right > window.innerWidth) menu.style.left = `${x - rect.width}px`;
|
|
676
|
+
if (rect.bottom > window.innerHeight) menu.style.top = `${y - rect.height}px`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Right-click on table rows → context menu
|
|
680
|
+
tbody?.addEventListener('contextmenu', (e: Event) => {
|
|
681
|
+
const me = e as MouseEvent;
|
|
682
|
+
const row = (me.target as Element).closest('tr[data-process-name]') as HTMLElement;
|
|
683
|
+
if (row && row.dataset.processName) {
|
|
684
|
+
me.preventDefault();
|
|
685
|
+
showContextMenu(row.dataset.processName, me.clientX, me.clientY);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// Close context menu on click outside or Escape
|
|
690
|
+
document.addEventListener('click', (e: Event) => {
|
|
691
|
+
if (contextMenuEl && !contextMenuEl.contains(e.target as Node)) {
|
|
692
|
+
closeContextMenu();
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
document.addEventListener('contextmenu', (e: Event) => {
|
|
696
|
+
// Allow right-click to close existing menu when clicking elsewhere
|
|
697
|
+
if (contextMenuEl && !contextMenuEl.contains(e.target as Node)) {
|
|
698
|
+
const row = (e.target as Element).closest('tr[data-process-name]');
|
|
699
|
+
if (!row) closeContextMenu();
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// Row click → open drawer (left click)
|
|
704
|
+
tbody?.addEventListener('click', (e: Event) => {
|
|
613
705
|
const row = (e.target as Element).closest('tr[data-process-name]') as HTMLElement;
|
|
614
706
|
if (row && row.dataset.processName) {
|
|
615
707
|
openDrawer(row.dataset.processName);
|
|
616
708
|
}
|
|
617
709
|
});
|
|
618
710
|
|
|
619
|
-
// Mobile cards click →
|
|
711
|
+
// Mobile cards click → keep inline buttons
|
|
620
712
|
const mobileCards = $('mobile-cards');
|
|
621
713
|
mobileCards?.addEventListener('click', (e: Event) => {
|
|
622
714
|
const btn = (e.target as Element).closest('[data-action]');
|
|
@@ -630,6 +722,22 @@ export default function mount(): () => void {
|
|
|
630
722
|
}
|
|
631
723
|
});
|
|
632
724
|
|
|
725
|
+
// Mobile cards → context menu on long-press
|
|
726
|
+
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
727
|
+
mobileCards?.addEventListener('touchstart', (e: Event) => {
|
|
728
|
+
const te = e as TouchEvent;
|
|
729
|
+
const card = (te.target as Element).closest('.process-card[data-process-name]') as HTMLElement;
|
|
730
|
+
if (card && card.dataset.processName) {
|
|
731
|
+
const name = card.dataset.processName;
|
|
732
|
+
const touch = te.touches[0];
|
|
733
|
+
longPressTimer = setTimeout(() => {
|
|
734
|
+
showContextMenu(name, touch.clientX, touch.clientY);
|
|
735
|
+
}, 500);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
mobileCards?.addEventListener('touchend', () => { if (longPressTimer) clearTimeout(longPressTimer); });
|
|
739
|
+
mobileCards?.addEventListener('touchmove', () => { if (longPressTimer) clearTimeout(longPressTimer); });
|
|
740
|
+
|
|
633
741
|
// ─── Detail Drawer ───
|
|
634
742
|
|
|
635
743
|
const drawer = $('detail-drawer');
|
|
@@ -1161,6 +1269,10 @@ export default function mount(): () => void {
|
|
|
1161
1269
|
return;
|
|
1162
1270
|
}
|
|
1163
1271
|
if (e.key === 'Escape') {
|
|
1272
|
+
if (contextMenuEl) {
|
|
1273
|
+
closeContextMenu();
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1164
1276
|
if (drawer?.classList.contains('open')) {
|
|
1165
1277
|
closeDrawer();
|
|
1166
1278
|
} else {
|
|
@@ -1220,6 +1332,7 @@ export default function mount(): () => void {
|
|
|
1220
1332
|
$('modal-create-btn')?.removeEventListener('click', createProcess);
|
|
1221
1333
|
$('refresh-btn')?.removeEventListener('click', loadProcesses);
|
|
1222
1334
|
document.removeEventListener('keydown', handleKeydown);
|
|
1335
|
+
closeContextMenu();
|
|
1223
1336
|
if (eventSource) eventSource.close();
|
|
1224
1337
|
if (logRefreshTimer) clearInterval(logRefreshTimer);
|
|
1225
1338
|
if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
|
package/dashboard/app/page.tsx
CHANGED
package/package.json
CHANGED