Recipes

Dev Ports

Lists active development ports with the process bound to each, plus a one-tap stop button. Docker-aware.

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 color and a renamed cmd per 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 kill field, and onTap.shell: "${item.kill}" runs whatever command the row needs. Docker-bound ports get docker stop, plain processes get kill.
  • Tight refresh intervalinterval: 5 keeps 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 to rename().
  • Restart instead of stop: replace docker stop with docker restart to bounce a flaky service in one tap.
  • Per-process emoji: branch on $1 (the process name) inside awk and emit an icon field, then bind it to a symbol.name in the row template.
  • Project-aware labels: shell out to lsof -p <pid> -d cwd and 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 -L recipe so a forwarded port shows up in the list with a custom label.