mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 00:01:34 +00:00
feat: show clickable port badges in top bar with live status
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
openWorktree,
|
||||
closeWorktree,
|
||||
sendPrompt,
|
||||
readEnvLocal,
|
||||
type Profile,
|
||||
} from "./workmux";
|
||||
import {
|
||||
@@ -21,6 +22,42 @@ import {
|
||||
|
||||
const PORT = parseInt(process.env.DASHBOARD_PORT || "5111");
|
||||
|
||||
/** Map branch name → worktree directory using git worktree list. */
|
||||
function getWorktreePaths(): Map<string, string> {
|
||||
const result = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe" });
|
||||
const output = new TextDecoder().decode(result.stdout);
|
||||
const paths = new Map<string, string>();
|
||||
let currentPath = "";
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
currentPath = line.slice("worktree ".length);
|
||||
} else if (line.startsWith("branch ")) {
|
||||
// branch refs/heads/foo → "foo"
|
||||
const branch = line.slice("branch ".length).replace("refs/heads/", "");
|
||||
// Also map by directory basename (workmux uses basename as branch key)
|
||||
const basename = currentPath.split("/").pop() ?? "";
|
||||
paths.set(branch, currentPath);
|
||||
if (basename !== branch) paths.set(basename, currentPath);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Check if a TCP port is listening by attempting a connection. */
|
||||
function isPortListening(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = Bun.connect({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
socket: {
|
||||
open(s) { s.end(); resolve(true); },
|
||||
error() { resolve(false); },
|
||||
data() {},
|
||||
},
|
||||
}).catch(() => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
@@ -129,12 +166,30 @@ async function handleApi(req: Request, url: URL): Promise<Response> {
|
||||
// GET /api/worktrees
|
||||
if (parts[0] === "worktrees" && parts.length === 1 && method === "GET") {
|
||||
const [worktrees, status] = await Promise.all([listWorktrees(), getStatus()]);
|
||||
const merged = worktrees.map(wt => {
|
||||
const wtPaths = getWorktreePaths();
|
||||
const merged = await Promise.all(worktrees.map(async (wt) => {
|
||||
const st = status.find(s =>
|
||||
s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch)
|
||||
);
|
||||
return { ...wt, status: st?.status ?? "", elapsed: st?.elapsed ?? "", title: st?.title ?? "" };
|
||||
});
|
||||
const wtDir = wtPaths.get(wt.branch);
|
||||
const env = wtDir ? readEnvLocal(wtDir) : {};
|
||||
const backendPort = env.BACKEND_PORT ? parseInt(env.BACKEND_PORT) : null;
|
||||
const frontendPort = env.FRONTEND_PORT ? parseInt(env.FRONTEND_PORT) : null;
|
||||
const [backendRunning, frontendRunning] = await Promise.all([
|
||||
backendPort ? isPortListening(backendPort) : false,
|
||||
frontendPort ? isPortListening(frontendPort) : false,
|
||||
]);
|
||||
return {
|
||||
...wt,
|
||||
status: st?.status ?? "",
|
||||
elapsed: st?.elapsed ?? "",
|
||||
title: st?.title ?? "",
|
||||
backendPort,
|
||||
frontendPort,
|
||||
backendRunning,
|
||||
frontendRunning,
|
||||
};
|
||||
}));
|
||||
return jsonResponse(merged);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ async function runChecked(args: string[]): Promise<string> {
|
||||
|
||||
export type Profile = "full" | "agent-only" | "agent-yolo";
|
||||
|
||||
function readEnvLocal(wtDir: string): Record<string, string> {
|
||||
export function readEnvLocal(wtDir: string): Record<string, string> {
|
||||
try {
|
||||
const content = Bun.spawnSync(["cat", `${wtDir}/.env.local`], { stdout: "pipe" });
|
||||
const text = new TextDecoder().decode(content.stdout).trim();
|
||||
|
||||
@@ -11,7 +11,25 @@
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-topbar border-b border-edge min-h-12">
|
||||
<span class="text-sm font-semibold">{name ?? "Select a worktree"}</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-semibold">{name ?? "Select a worktree"}</span>
|
||||
{#if worktree?.backendPort}
|
||||
<a
|
||||
href="{window.location.protocol}//{window.location.hostname}:{worktree.backendPort}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[11px] px-1.5 py-0.5 rounded border font-mono no-underline hover:opacity-80 {worktree.backendRunning ? 'text-success border-success/40' : 'text-muted border-edge pointer-events-none'}"
|
||||
>BE :{worktree.backendPort}</a>
|
||||
{/if}
|
||||
{#if worktree?.frontendPort}
|
||||
<a
|
||||
href="{window.location.protocol}//{window.location.hostname}:{worktree.frontendPort}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[11px] px-1.5 py-0.5 rounded border font-mono no-underline hover:opacity-80 {worktree.frontendRunning ? 'text-success border-success/40' : 'text-muted border-edge pointer-events-none'}"
|
||||
>FE :{worktree.frontendPort}</a>
|
||||
{/if}
|
||||
</div>
|
||||
{#if name}
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-xs px-2 py-0.5 rounded-xl bg-hover">{worktree?.status || worktree?.agent || ""}</span>
|
||||
|
||||
@@ -6,4 +6,8 @@ export interface WorktreeInfo {
|
||||
status: string;
|
||||
elapsed: string;
|
||||
title: string;
|
||||
backendPort: number | null;
|
||||
frontendPort: number | null;
|
||||
backendRunning: boolean;
|
||||
frontendRunning: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user