Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
Fusion
2026-05-22 21:39:59 +02:00
5 changed files with 160 additions and 39 deletions
+23 -4
View File
@@ -124,7 +124,15 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
try:
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
except RuntimeError:
_run_git_command(repo_path, "checkout", "--orphan", name)
# No commits yet - empty repository
try:
_run_git_command(repo_path, "checkout", "--orphan", name)
except RuntimeError as e:
if "work tree" in str(e).lower():
# Bare repository - use symbolic-ref instead
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
return
raise
return
_run_git_command(repo_path, "branch", name, base_branch)
@@ -155,7 +163,14 @@ def checkout_branch(repo_path: str, name: str) -> None:
Raises:
RuntimeError: If checkout fails
"""
_run_git_command(repo_path, "checkout", name)
try:
_run_git_command(repo_path, "checkout", name)
except RuntimeError as e:
if "work tree" in str(e).lower():
# Bare repository - use symbolic-ref instead
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
return
raise
def commit_changes(
@@ -290,6 +305,10 @@ def get_current_branch(repo_path: str) -> str:
Current branch name
"""
try:
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
if branch != "HEAD":
return branch
except RuntimeError:
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
pass
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
@@ -70,6 +70,20 @@ def test_get_current_branch_handles_unborn_main() -> None:
assert get_current_branch(tmpdir) == "main"
def test_create_branch_on_bare_repo_with_no_commits() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
create_branch(f"{tmpdir}/bare.git", "main")
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
def test_checkout_branch_on_bare_repo_with_no_commits() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
checkout_branch(f"{tmpdir}/bare.git", "main")
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
class TestBranchOperations:
"""Tests for branch management functions."""
+3 -3
View File
@@ -9,8 +9,9 @@ import { useSessions } from "../state/sessions";
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/settings", label: "Settings", icon: "settings" }
@@ -83,7 +84,6 @@ export const AppShell = () => {
<div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
<NavLink
@@ -94,7 +94,7 @@ export const AppShell = () => {
>
<Icon name={item.icon} size="sm" />
{item.label}
{isHome && activeCount > 0 && (
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
+118 -31
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types";
@@ -34,6 +34,9 @@ export const HomePage = () => {
const [displayName, setDisplayName] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
const safeSessions = Array.isArray(sessions) ? sessions : [];
const loadHome = useCallback(async () => {
@@ -59,6 +62,49 @@ export const HomePage = () => {
void loadHome();
}, [loadHome]);
// Poll tunnel health every 30 seconds for running instances
useEffect(() => {
const checkHealth = async () => {
const runningSessions = safeSessions.filter(
(s) => s.status === "running" && s.url
);
for (const session of runningSessions) {
try {
const health = await checkInstanceHealth(
session.project_id,
session.repository_id,
session.id
);
setTunnelHealth((prev) => ({
...prev,
[session.id]: health,
}));
} catch {
setTunnelHealth((prev) => ({
...prev,
[session.id]: {
healthy: false,
container_status: "unknown",
container_health: null,
container_exit_code: null,
tunnel_status: "error",
tunnel_status_code: null,
probe_status: "error",
last_probe_output: null,
error: "check failed",
},
}));
}
}
};
void checkHealth();
const interval = setInterval(() => {
void checkHealth();
}, 30000);
return () => clearInterval(interval);
}, [safeSessions]);
useEffect(() => {
if (!selectedProject) {
setRepositories([]);
@@ -120,7 +166,12 @@ export const HomePage = () => {
};
const handleStop = async (session: SessionView) => {
if (stopConfirmId !== session.id) {
setStopConfirmId(session.id);
return;
}
setActionBusy(session.id);
setStopConfirmId(null);
try {
await stopInstance(session.project_id, session.repository_id, session.id);
await loadHome();
@@ -130,10 +181,17 @@ export const HomePage = () => {
};
const handleDelete = async (session: SessionView) => {
if (deleteConfirmId !== session.id) {
setDeleteConfirmId(session.id);
return;
}
setActionBusy(session.id);
setDeleteConfirmId(null);
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
await loadHome();
setSessions((prev) => prev.filter((s) => s.id !== session.id));
} catch {
// error - session remains in state
} finally {
setActionBusy(null);
}
@@ -203,36 +261,65 @@ export const HomePage = () => {
<p className="muted">No active sessions right now.</p>
) : (
<div className="home-session-grid">
{activeSessions.map((session) => (
<article className="card session-card" key={session.id}>
<div className="stack-sm">
<div className="row row-tight">
<h3>{session.display_name}</h3>
<span className={`status-badge ${session.status}`}>{session.status}</span>
{activeSessions.map((session) => {
const health = tunnelHealth[session.id];
const isUnhealthy = health && !health.healthy;
return (
<article className="card session-card" key={session.id}>
<div className="stack-sm">
<div className="row row-tight">
<h3>{session.display_name}</h3>
<div className="row row-tight">
{isUnhealthy && (
<span className="status-badge error" title={health.error || "unhealthy"}>!</span>
)}
<span className={`status-badge ${session.status}`}>{session.status}</span>
</div>
</div>
<p className="muted">{session.project_name} · {session.repository_name}</p>
<p className="muted">{session.tool_type_name}</p>
</div>
<p className="muted">{session.project_name} · {session.repository_name}</p>
<p className="muted">{session.tool_type_name}</p>
</div>
<div className="session-actions">
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
<Icon name="external" size="sm" />
Open
</button>
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
<Icon name="refresh" size="sm" />
Tunnel
</button>
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<Icon name="stop" size="sm" />
Stop
</button>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
</article>
))}
<div className="session-actions">
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
<Icon name="external" size="sm" />
Open
</button>
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
<Icon name="refresh" size="sm" />
Tunnel
</button>
{stopConfirmId === session.id ? (
<div className="stop-confirm-inline">
<span className="confirm-text">Stop?</span>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<Icon name="stop" size="sm" /> Stop
</button>
<button className="ghost-button small" type="button" onClick={() => setStopConfirmId(null)}>Cancel</button>
</div>
) : (
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<Icon name="stop" size="sm" />
Stop
</button>
)}
{deleteConfirmId === session.id ? (
<div className="delete-confirm-inline">
<span className="confirm-text">Delete?</span>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" /> Delete
</button>
<button className="ghost-button small" type="button" onClick={() => setDeleteConfirmId(null)}>Cancel</button>
</div>
) : (
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" />
Delete
</button>
)}
</div>
</article>
);
})}
</div>
)}
</section>
+2 -1
View File
@@ -16,12 +16,12 @@ import { ToolWorkshopPage } from "./pages/tool-workshop";
import { SSHKeysPage } from "./pages/ssh-keys";
import { ToolConfigsPage } from "./pages/tool-configs";
import { ToolTypesPage } from "./pages/tool-types";
import { SessionsPage } from "./pages/sessions";
export const AppRouter = () => {
return (
<Routes>
<Route path="/login" element={<LoginRedirectPage />} />
<Route path="/sessions" element={<Navigate to="/" replace />} />
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
@@ -48,6 +48,7 @@ export const AppRouter = () => {
<Route path="tool-configs" element={<ToolConfigsPage />} />
<Route path="*" element={<Navigate to="general" replace />} />
</Route>
<Route path="sessions" element={<SessionsPage />} />
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
</Route>