Scans common development ports (Node, Vite, Angular, Storybook, Expo, Supabase, etc.) and lists the ones currently listening, sorted numerically. Each row shows the bound process and a tap-to-stop button. Supabase services running through Docker are detected by port and stopped via docker stop so the daemon stays up.
yaml
id: dev-ports
interval: 5
order: 25
location: tray
tooltip: "Active dev ports"
command: |
lsof -nP +c 0 -iTCP -sTCP:LISTEN 2>/dev/null | awk '
BEGIN {
split("3000 3001 3030 4000 4173 4200 5001 5173 5174 5273 6006 8000 8001 8080 8081 8888 9000 9090 19000 19001 19002 19006 54321 54322 54323 54324 54327 54328", arr)
for (i in arr) common[arr[i]] = 1
}
function color(p) {
if (p >= 54000 && p < 55000) return "#3ECF8E"
if (p < 4000) return "#7EE787"
if (p < 5000) return "#FFB454"
if (p < 6000) return "#A371F7"
if (p < 7000) return "#F778BA"
if (p < 9000) return "#79C0FF"
if (p < 10000) return "#56D4DD"
return "#D2A8FF"
}
function rename(p, c) {
if (p == 54321) return "supabase api"
if (p == 54322) return "supabase db"
if (p == 54323) return "supabase studio"
if (p == 54324) return "supabase inbucket"
if (p == 54327) return "supabase analytics"
if (p == 54328) return "supabase vector"
return c
}
NR > 1 {
n = split($9, a, ":")
port = a[n]
if (port in common && !seen[port,$2]++) {
if ($1 ~ /docker/) {
kcmd = "docker stop $(docker ps -q --filter publish=" port ") 2>/dev/null"
} else {
kcmd = "kill " $2 " 2>/dev/null"
}
items[++count] = port "|" $2 "|" rename(port+0, $1) "|" color(port+0) "|" kcmd
}
}
END {
for (i=1; i<=count; i++) for (j=i+1; j<=count; j++) {
split(items[i], a, "|"); split(items[j], b, "|")
if (a[1]+0 > b[1]+0) { t = items[i]; items[i] = items[j]; items[j] = t }
}
printf "{\"count\":%d,\"ports\":[", count
for (i=1; i<=count; i++) {
split(items[i], a, "|")
if (i>1) printf ","
printf "{\"port\":%d,\"pid\":%d,\"cmd\":\"%s\",\"color\":\"%s\",\"kill\":\"%s\"}", a[1], a[2], a[3], a[4], a[5]
}
print "]}"
}'
view:
hstack:
spacing: 5
children:
- symbol: { name: "powerplug.portrait.fill", size: 12, color: "#4FC3F7" }
- if:
when: "${count} > 0"
then:
text:
content: "${count}"
size: 11
weight: semibold
design: rounded
monospacedDigit: true
hover:
vstack:
spacing: 10
alignment: leading
padding: { all: 4 }
frame: { width: 300 }
children:
- hstack:
spacing: 8
children:
- symbol: { name: "powerplug.portrait.fill", size: 12, color: "#4FC3F7" }
- text: { content: "Dev Ports", size: 12, weight: semibold }
- spacer: {}
- text:
content: "${count} active"
size: 10
color: secondary
monospacedDigit: true
padding: { vertical: 2, horizontal: 7 }
background: "#FFFFFF14"
cornerRadius: 8
- if:
when: "${count} == 0"
then:
hstack:
spacing: 8
padding: { vertical: 12, horizontal: 10 }
frame: { maxWidth: 9999 }
children:
- spacer: {}
- symbol: { name: "checkmark.circle", size: 14, color: "#7EE787" }
- text: { content: "No active dev ports", size: 11, color: secondary }
- spacer: {}
else:
forEach:
in: "${ports}"
stack: vstack
spacing: 5
alignment: leading
template:
hstack:
spacing: 10
padding: { vertical: 7, horizontal: 9 }
background: "#FFFFFF0E"
cornerRadius: 8
children:
- text:
content: ":${item.port}"
size: 12
design: monospaced
weight: semibold
color: "${item.color}"
monospacedDigit: true
frame: { width: 66 }
- vstack:
alignment: leading
spacing: 1
children:
- text: { content: "${item.cmd}", size: 12, weight: medium, lineLimit: 1 }
- text: { content: "PID ${item.pid}", size: 9, color: secondary, design: monospaced, monospacedDigit: true }
- spacer: {}
- symbol:
name: "xmark"
size: 9
weight: bold
color: "#FF8A8A"
padding: { all: 5 }
background: "#FF6B6B22"
cornerRadius: 999
tooltip: "Stop ${item.cmd} on :${item.port}"
onTap:
shell: "${item.kill}"
What this teaches
- Computed colors and labels — the awk script emits a
colorand a renamedcmdper row, so the view stays declarative. Anything you can compute in the command becomes a binding. - Per-row dynamic shell action — each row carries its own
killfield, andonTap.shell: "${item.kill}"runs whatever command the row needs. Docker-bound ports getdocker stop, plain processes getkill. - Tight refresh interval —
interval: 5keeps the list responsive when you start and stop dev servers. - Friendly empty state — when nothing is listening, a centered green check sits in place of an empty list.
- Grouped color coding — port ranges map to a palette (3xxx green, 4xxx orange, 5xxx purple, 54xxx Supabase green) so families of services are recognizable at a glance.
Building on this
- Add your own port: append it to the
split(...)list and, if you want a friendly name, add a case torename(). - Restart instead of stop: replace
docker stopwithdocker restartto bounce a flaky service in one tap. - Per-process emoji: branch on
$1(the process name) inside awk and emit aniconfield, then bind it to asymbol.namein the row template. - Project-aware labels: shell out to
lsof -p <pid> -d cwdand emit the working directory as a subtitle to remind yourself which repo a port belongs to. - Watch a remote tunnel: pair this with an
ssh -Lrecipe so a forwarded port shows up in the list with a custom label.