Skip to content

Commit 1a4c6e7

Browse files
committed
feat: config groups, hot reload of configs
1 parent 6065280 commit 1a4c6e7

10 files changed

Lines changed: 707 additions & 69 deletions

File tree

README.md

Lines changed: 90 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ A terminal UI application for managing your local development environment - hot
77
## Features
88

99
- **Unified Process Management** - Start, stop, and restart multiple services from a single TUI
10+
- **Service Groups** - Organize services into collapsible groups for better organization
1011
- **Dependency Ordering** - Services start in the correct order based on dependencies
1112
- **Health Checks** - Wait for services to be healthy before starting dependents
12-
- **Live Log Streaming** - View logs from all services or focus on one
13+
- **Live Log Streaming** - View logs from all services or focus on one with scrollback
14+
- **Hot Config Reload** - Update your config without restarting DevProc
1315
- **Keyboard-Driven** - Fast navigation with vim-style keybindings
1416

1517
## Installation
@@ -47,6 +49,14 @@ Download the latest binary from [GitHub Releases](https://github.com/captjt/devp
4749
```yaml
4850
name: my-project
4951

52+
# Optional: organize services into groups
53+
groups:
54+
backend:
55+
- api
56+
- worker
57+
frontend:
58+
- web
59+
5060
services:
5161
api:
5262
cmd: go run ./cmd/api
@@ -89,6 +99,17 @@ env:
8999
# Load from .env file
90100
dotenv: .env.local
91101

102+
# Organize services into groups (optional)
103+
groups:
104+
infrastructure:
105+
- postgres
106+
- redis
107+
backend:
108+
- api
109+
- worker
110+
frontend:
111+
- web
112+
92113
services:
93114
postgres:
94115
cmd: docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16
@@ -177,22 +198,62 @@ healthcheck:
177198
retries: 30
178199
```
179200

201+
### Service Groups
202+
203+
Groups let you organize related services together in the UI. Services in a group are displayed under a collapsible header showing the group name and running count.
204+
205+
```yaml
206+
groups:
207+
backend:
208+
- api
209+
- worker
210+
frontend:
211+
- web
212+
- storybook
213+
```
214+
215+
- Groups appear in the order they're defined in the config
216+
- Services not in any group appear at the bottom
217+
- Press `Space` to collapse/expand a group when a service in that group is selected
218+
180219
## Keyboard Shortcuts
181220

182-
| Key | Action |
183-
| -------------- | --------------------------- |
184-
| `↑/↓` or `j/k` | Navigate services |
185-
| `s` | Start selected service |
186-
| `x` | Stop selected service |
187-
| `r` | Restart selected service |
188-
| `a` | Start all services |
189-
| `X` (shift) | Stop all services |
190-
| `R` (shift) | Restart all services |
191-
| `Tab` | Toggle single/all logs view |
192-
| `c` | Clear logs |
193-
| `f` | Toggle follow mode |
194-
| `?` | Show help |
195-
| `q` | Quit (stops all services) |
221+
### Services
222+
223+
| Key | Action |
224+
| -------------- | ------------------------ |
225+
| `↑/↓` or `j/k` | Navigate services |
226+
| `s` | Start selected service |
227+
| `x` | Stop selected service |
228+
| `r` | Restart selected service |
229+
| `a` | Start all services |
230+
| `X` (shift) | Stop all services |
231+
| `R` (shift) | Restart all services |
232+
| `Space` | Toggle group collapsed |
233+
234+
### Logs
235+
236+
| Key | Action |
237+
| --------------- | --------------------------- |
238+
| `Tab` | Toggle single/all logs view |
239+
| `c` | Clear logs |
240+
| `f` | Toggle follow mode |
241+
| `g` / `G` | Scroll to top / bottom |
242+
| `PgUp` / `PgDn` | Page up / down |
243+
| `Ctrl+u/d` | Half page up / down |
244+
245+
### Config
246+
247+
| Key | Action |
248+
| -------- | ----------------------- |
249+
| `Ctrl+L` | Reload config from disk |
250+
251+
### General
252+
253+
| Key | Action |
254+
| --- | ------------------------- |
255+
| `?` | Show help |
256+
| `q` | Quit (stops all services) |
196257

197258
## CLI Options
198259

@@ -207,10 +268,24 @@ Commands:
207268
208269
Options:
209270
-c, --config Path to config file (default: devproc.yaml)
271+
-w, --watch Watch config file and auto-reload on changes
210272
-h, --help Show help
211273
-v, --version Show version
212274
```
213275

276+
### Config Reload
277+
278+
DevProc supports hot-reloading your configuration without restarting:
279+
280+
- **Manual reload**: Press `Ctrl+L` to reload the config file
281+
- **Auto reload**: Run with `-w` flag to watch for file changes
282+
283+
When config is reloaded:
284+
285+
- New services are added (in stopped state)
286+
- Removed services are stopped and removed
287+
- Modified services are restarted with the new configuration
288+
214289
## Requirements
215290

216291
- [Bun](https://bun.sh) >= 1.0

devproc.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ name: example-project
77
env:
88
NODE_ENV: development
99

10+
# Service groups - organize related services together
11+
groups:
12+
core:
13+
- echo
14+
- counter
15+
monitoring:
16+
- logger
17+
- metrics
18+
1019
# Example services for a typical full-stack application
1120
services:
1221
# Simple echo service for testing
@@ -25,6 +34,11 @@ services:
2534
logger:
2635
cmd: "bash -c 'while true; do date; sleep 3; done'"
2736
color: yellow
37+
38+
# Metrics service
39+
metrics:
40+
cmd: 'bash -c ''while true; do echo "Memory: $((RANDOM % 100))% CPU: $((RANDOM % 100))%"; sleep 5; done'''
41+
color: magenta
2842
# Example with Docker (commented out - uncomment if you have Docker)
2943
# postgres:
3044
# cmd: docker run --rm --name devproc-pg -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16

screenshot.png

62.9 KB
Loading

src/app.tsx

Lines changed: 142 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { createSignal, createEffect, onMount, onCleanup, For, Show } from "solid-js";
1+
import { createSignal, createEffect, onMount, onCleanup, For, Show, Switch, Match } from "solid-js";
22
import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
33
import { TextAttributes } from "@opentui/core";
44
import type { ScrollBoxRenderable } from "@opentui/core";
55
import type { ProcessManager } from "./process/manager";
6-
import { useServices } from "./ui/hooks/useServices";
6+
import { useServices, type DisplayItem } from "./ui/hooks/useServices";
77
import { useLogs } from "./ui/hooks/useLogs";
88

99
interface AppProps {
@@ -54,11 +54,23 @@ function formatLogTime(date: Date): string {
5454

5555
export function App(props: AppProps) {
5656
const dimensions = useTerminalDimensions();
57-
const { services, selectedIndex, selectedService, selectNext, selectPrev } = useServices(
58-
props.manager,
59-
);
57+
const {
58+
services,
59+
displayItems,
60+
selectedIndex,
61+
selectedService,
62+
selectedGroup,
63+
selectedName,
64+
selectNext,
65+
selectPrev,
66+
toggleGroupCollapsed,
67+
isGroupCollapsed,
68+
} = useServices(props.manager);
6069
const { getServiceLogs, clearLogs, filteredLogs } = useLogs(props.manager);
6170

71+
// Status message for reload feedback
72+
const [statusMessage, setStatusMessage] = createSignal<string | null>(null);
73+
6274
const [viewMode, setViewMode] = createSignal<"single" | "all">("single");
6375
const [showHelp, setShowHelp] = createSignal(false);
6476
const [following, setFollowing] = createSignal(true);
@@ -295,6 +307,64 @@ export function App(props: AppProps) {
295307
event.preventDefault();
296308
}
297309
break;
310+
311+
// Config reload
312+
case "l":
313+
if (event.ctrl) {
314+
// Ctrl+L - reload config
315+
setStatusMessage("Reloading config...");
316+
props.manager
317+
.reloadConfig()
318+
.then((result) => {
319+
const changes = [
320+
result.added.length > 0 ? `+${result.added.length}` : "",
321+
result.removed.length > 0 ? `-${result.removed.length}` : "",
322+
result.modified.length > 0 ? `~${result.modified.length}` : "",
323+
]
324+
.filter(Boolean)
325+
.join(" ");
326+
setStatusMessage(changes ? `Reloaded: ${changes}` : "Config unchanged");
327+
setTimeout(() => setStatusMessage(null), 3000);
328+
})
329+
.catch((err) => {
330+
setStatusMessage(`Reload failed: ${err.message}`);
331+
setTimeout(() => setStatusMessage(null), 5000);
332+
});
333+
event.preventDefault();
334+
}
335+
break;
336+
337+
// Group operations
338+
case "space":
339+
case " ":
340+
// Toggle group collapsed (if current service is in a group)
341+
const group = selectedGroup();
342+
if (group) {
343+
toggleGroupCollapsed(group);
344+
}
345+
event.preventDefault();
346+
break;
347+
348+
case "1":
349+
case "2":
350+
case "3":
351+
case "4":
352+
case "5":
353+
case "6":
354+
case "7":
355+
case "8":
356+
case "9":
357+
// Quick group operations with number keys + shift
358+
if (event.shift) {
359+
const groupIdx = parseInt(event.name) - 1;
360+
const groups = Array.from(props.manager.getGroups().keys());
361+
if (groupIdx < groups.length) {
362+
const groupName = groups[groupIdx]!;
363+
// Start the group
364+
props.manager.startGroup(groupName).catch(console.error);
365+
}
366+
}
367+
break;
298368
}
299369
},
300370
);
@@ -316,30 +386,69 @@ export function App(props: AppProps) {
316386
<box flexDirection="row" flexGrow={1}>
317387
{/* Service list */}
318388
<box width={sidebarWidth()} flexDirection="column" borderStyle="rounded" borderColor="gray">
319-
<box height={1} paddingLeft={1}>
389+
<box height={1} paddingLeft={1} flexDirection="row">
320390
<text fg="cyan" attributes={TextAttributes.BOLD}>
321391
Services
322392
</text>
393+
<box flexGrow={1} />
394+
<Show when={statusMessage()}>
395+
<text fg="yellow">{statusMessage()}</text>
396+
</Show>
323397
</box>
324398
<box flexDirection="column" flexGrow={1}>
325-
<For each={services()}>
326-
{(service, index) => (
327-
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
328-
<text fg={STATUS_COLORS[service.status]}>{STATUS_SYMBOLS[service.status]}</text>
329-
<text> </text>
330-
<text
331-
fg={index() === selectedIndex() ? "white" : undefined}
332-
attributes={
333-
index() === selectedIndex() ? TextAttributes.BOLD : TextAttributes.NONE
334-
}
335-
flexGrow={1}
336-
>
337-
{service.name}
338-
</text>
339-
<text fg="gray">{service.port ? `:${service.port}` : ""}</text>
340-
<text> </text>
341-
<text fg="gray">{formatUptime(service.startedAt)}</text>
342-
</box>
399+
<For each={displayItems()}>
400+
{(item) => (
401+
<Switch>
402+
<Match when={item.type === "group"}>
403+
{(() => {
404+
const groupItem = item as DisplayItem & { type: "group" };
405+
const collapsed = isGroupCollapsed(groupItem.group.name);
406+
const runningCount = groupItem.services.filter(
407+
(s) => s.status === "running" || s.status === "healthy",
408+
).length;
409+
return (
410+
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
411+
<text fg="magenta">{collapsed ? "▸" : "▾"}</text>
412+
<text> </text>
413+
<text fg="magenta" attributes={TextAttributes.BOLD}>
414+
{groupItem.group.name}
415+
</text>
416+
<text fg="gray">
417+
{" "}
418+
({runningCount}/{groupItem.services.length})
419+
</text>
420+
</box>
421+
);
422+
})()}
423+
</Match>
424+
<Match when={item.type === "service"}>
425+
{(() => {
426+
const serviceItem = item as DisplayItem & { type: "service" };
427+
const service = serviceItem.service;
428+
const isSelected = selectedName() === service.name;
429+
const indent = serviceItem.group ? " " : "";
430+
return (
431+
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
432+
<text>{indent}</text>
433+
<text fg={STATUS_COLORS[service.status]}>
434+
{STATUS_SYMBOLS[service.status]}
435+
</text>
436+
<text> </text>
437+
<text
438+
fg={isSelected ? "white" : undefined}
439+
attributes={isSelected ? TextAttributes.BOLD : TextAttributes.NONE}
440+
flexGrow={1}
441+
>
442+
{service.name}
443+
</text>
444+
<text fg="gray">{service.port ? `:${service.port}` : ""}</text>
445+
<text> </text>
446+
<text fg="gray">{formatUptime(service.startedAt)}</text>
447+
</box>
448+
);
449+
})()}
450+
</Match>
451+
</Switch>
343452
)}
344453
</For>
345454
</box>
@@ -428,21 +537,18 @@ export function App(props: AppProps) {
428537
</text>
429538
<text> </text>
430539
<text fg="yellow">Services</text>
431-
<text>↑/↓ or j/k Navigate services</text>
432-
<text>s Start selected service</text>
433-
<text>x Stop selected / X Stop all</text>
434-
<text>r Restart selected / R Restart all</text>
435-
<text>a Start all services</text>
540+
<text>↑/↓ j/k Navigate | s Start | x Stop | r Restart</text>
541+
<text>a Start all | X Stop all | R Restart all</text>
542+
<text>Space Toggle group collapsed</text>
436543
<text> </text>
437544
<text fg="yellow">Logs</text>
438-
<text>Tab Toggle single/all logs view</text>
439-
<text>c Clear logs</text>
440-
<text>f Toggle follow mode</text>
441-
<text>g/G Scroll to top/bottom</text>
442-
<text>PgUp/PgDn Page up/down</text>
443-
<text>Ctrl+u/d Half page up/down</text>
545+
<text>Tab Toggle view | c Clear | f Follow</text>
546+
<text>g/G Top/bottom | PgUp/PgDn | Ctrl+u/d</text>
547+
<text> </text>
548+
<text fg="yellow">Config</text>
549+
<text>Ctrl+L Reload config from disk</text>
444550
<text> </text>
445-
<text>? Help q Quit</text>
551+
<text>? Help | q Quit</text>
446552
<text> </text>
447553
<text fg="gray">Press any key to close</text>
448554
</box>

0 commit comments

Comments
 (0)