feat: single-port architecture and browser warning fixes (#390)
This commit is contained in:
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
# docker build -t kandev:latest .
|
# docker build -t kandev:latest .
|
||||||
#
|
#
|
||||||
# Run:
|
# Run:
|
||||||
# docker run -p 8080:8080 -p 3000:3000 -v kandev-data:/data kandev:latest
|
# docker run -p 8080:8080 -v kandev-data:/data kandev:latest
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Stage 1: Go builder — compile kandev + agentctl binaries
|
# Stage 1: Go builder — compile kandev + agentctl binaries
|
||||||
@@ -110,7 +110,7 @@ RUN chown -R kandev:kandev /app /data
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
EXPOSE 8080 3000
|
EXPOSE 8080
|
||||||
|
|
||||||
# tini as PID 1 for signal handling; entrypoint handles privilege drop
|
# tini as PID 1 for signal handling; entrypoint handles privilege drop
|
||||||
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
|
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -383,6 +386,7 @@ type routeParams struct {
|
|||||||
msgCreator *messageCreatorAdapter
|
msgCreator *messageCreatorAdapter
|
||||||
secretsSvc *secrets.Service
|
secretsSvc *secrets.Service
|
||||||
secretStore secrets.SecretStore
|
secretStore secrets.SecretStore
|
||||||
|
webInternalURL string
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +405,33 @@ func registerRoutes(p routeParams) {
|
|||||||
p.router.GET("/health", func(c *gin.Context) {
|
p.router.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kandev", "mode": "websocket+http"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kandev", "mode": "websocket+http"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if p.webInternalURL != "" {
|
||||||
|
target, err := url.Parse(p.webInternalURL)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Error("Invalid web internal URL, skipping reverse proxy", zap.String("url", p.webInternalURL), zap.Error(err))
|
||||||
|
} else {
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
proxy.FlushInterval = -1
|
||||||
|
// Custom error handler to prevent the default behavior of panicking
|
||||||
|
// with http.ErrAbortHandler when the upstream connection fails or the
|
||||||
|
// client disconnects. Without this, Gin's recovery middleware catches
|
||||||
|
// the panic and logs a full (harmless but noisy) stack trace.
|
||||||
|
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||||
|
p.log.Debug("Web proxy error", zap.Error(err))
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
p.router.NoRoute(func(c *gin.Context) {
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
if strings.HasPrefix(path, "/api/") || path == "/ws" || path == "/health" {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
p.log.Info("Web reverse proxy enabled", zap.String("target", p.webInternalURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerTaskRoutes registers all task-related HTTP and WebSocket routes.
|
// registerTaskRoutes registers all task-related HTTP and WebSocket routes.
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ func buildHTTPServer(
|
|||||||
msgCreator: msgCreator,
|
msgCreator: msgCreator,
|
||||||
secretsSvc: secrets.NewService(repos.Secrets, log),
|
secretsSvc: secrets.NewService(repos.Secrets, log),
|
||||||
secretStore: repos.Secrets,
|
secretStore: repos.Secrets,
|
||||||
|
webInternalURL: cfg.Server.WebInternalURL,
|
||||||
log: log,
|
log: log,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -50,10 +50,11 @@ func (c *Config) ResolvedDataDir() string {
|
|||||||
|
|
||||||
// ServerConfig holds HTTP server configuration.
|
// ServerConfig holds HTTP server configuration.
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
ReadTimeout int `mapstructure:"readTimeout"` // in seconds
|
ReadTimeout int `mapstructure:"readTimeout"` // in seconds
|
||||||
WriteTimeout int `mapstructure:"writeTimeout"` // in seconds
|
WriteTimeout int `mapstructure:"writeTimeout"` // in seconds
|
||||||
|
WebInternalURL string `mapstructure:"webInternalUrl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseConfig holds database connection configuration.
|
// DatabaseConfig holds database connection configuration.
|
||||||
@@ -197,6 +198,7 @@ func setDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("server.port", 8080)
|
v.SetDefault("server.port", 8080)
|
||||||
v.SetDefault("server.readTimeout", 30)
|
v.SetDefault("server.readTimeout", 30)
|
||||||
v.SetDefault("server.writeTimeout", 30)
|
v.SetDefault("server.writeTimeout", 30)
|
||||||
|
v.SetDefault("server.webInternalUrl", "")
|
||||||
|
|
||||||
// DataDir default — empty means resolve from KANDEV_DATA_DIR env or ~/.kandev/data
|
// DataDir default — empty means resolve from KANDEV_DATA_DIR env or ~/.kandev/data
|
||||||
v.SetDefault("dataDir", "")
|
v.SetDefault("dataDir", "")
|
||||||
@@ -312,6 +314,7 @@ func LoadWithPath(configPath string) (*Config, error) {
|
|||||||
_ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST")
|
_ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST")
|
||||||
_ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT")
|
_ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT")
|
||||||
_ = v.BindEnv("agent.mcpServerUrl", "KANDEV_AGENT_MCP_SERVER_URL")
|
_ = v.BindEnv("agent.mcpServerUrl", "KANDEV_AGENT_MCP_SERVER_URL")
|
||||||
|
_ = v.BindEnv("server.webInternalUrl", "KANDEV_WEB_INTERNAL_URL")
|
||||||
_ = v.BindEnv("dataDir", "KANDEV_DATA_DIR")
|
_ = v.BindEnv("dataDir", "KANDEV_DATA_DIR")
|
||||||
_ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL")
|
_ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL")
|
||||||
_ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE")
|
_ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE")
|
||||||
|
|||||||
+1
-2
@@ -28,7 +28,7 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
|
|||||||
KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true",
|
KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true",
|
||||||
};
|
};
|
||||||
const backendEnv = buildBackendEnv({ ports, extra });
|
const backendEnv = buildBackendEnv({ ports, extra });
|
||||||
const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true });
|
const webEnv = buildWebEnv({ ports, debug: true });
|
||||||
const logLevel =
|
const logLevel =
|
||||||
process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info";
|
process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info";
|
||||||
|
|
||||||
@@ -36,7 +36,6 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
|
|||||||
header: "dev mode: using local repo",
|
header: "dev mode: using local repo",
|
||||||
ports,
|
ports,
|
||||||
dbPath,
|
dbPath,
|
||||||
includeMcp: true,
|
|
||||||
logLevel,
|
logLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -174,6 +174,7 @@ async function prepareReleaseBundle({
|
|||||||
const backendEnv = {
|
const backendEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
KANDEV_SERVER_PORT: String(actualBackendPort),
|
KANDEV_SERVER_PORT: String(actualBackendPort),
|
||||||
|
KANDEV_WEB_INTERNAL_URL: `http://localhost:${actualWebPort}`,
|
||||||
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
|
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
|
||||||
KANDEV_DATABASE_PATH: dbPath,
|
KANDEV_DATABASE_PATH: dbPath,
|
||||||
KANDEV_LOG_LEVEL: logLevel,
|
KANDEV_LOG_LEVEL: logLevel,
|
||||||
@@ -182,7 +183,6 @@ async function prepareReleaseBundle({
|
|||||||
const webEnv: NodeJS.ProcessEnv = {
|
const webEnv: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
KANDEV_API_BASE_URL: backendUrl,
|
KANDEV_API_BASE_URL: backendUrl,
|
||||||
NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl,
|
|
||||||
PORT: String(actualWebPort),
|
PORT: String(actualWebPort),
|
||||||
};
|
};
|
||||||
(webEnv as Record<string, string>).NODE_ENV = "production";
|
(webEnv as Record<string, string>).NODE_ENV = "production";
|
||||||
@@ -264,6 +264,6 @@ export async function runRelease({ version, backendPort, webPort }: RunOptions):
|
|||||||
quiet: true,
|
quiet: true,
|
||||||
});
|
});
|
||||||
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
|
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
|
||||||
console.log(`[kandev] web ready at ${webUrl}`);
|
console.log("[kandev] ready at " + prepared.backendUrl);
|
||||||
openBrowser(webUrl);
|
openBrowser(prepared.backendUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-22
@@ -67,6 +67,7 @@ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv {
|
|||||||
return {
|
return {
|
||||||
...process.env,
|
...process.env,
|
||||||
KANDEV_SERVER_PORT: String(ports.backendPort),
|
KANDEV_SERVER_PORT: String(ports.backendPort),
|
||||||
|
KANDEV_WEB_INTERNAL_URL: `http://localhost:${ports.webPort}`,
|
||||||
KANDEV_AGENT_STANDALONE_PORT: String(ports.agentctlPort),
|
KANDEV_AGENT_STANDALONE_PORT: String(ports.agentctlPort),
|
||||||
KANDEV_AGENT_MCP_SERVER_PORT: String(ports.mcpPort),
|
KANDEV_AGENT_MCP_SERVER_PORT: String(ports.mcpPort),
|
||||||
...(logLevel ? { KANDEV_LOG_LEVEL: logLevel } : {}),
|
...(logLevel ? { KANDEV_LOG_LEVEL: logLevel } : {}),
|
||||||
@@ -76,8 +77,6 @@ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv {
|
|||||||
|
|
||||||
export type WebEnvOptions = {
|
export type WebEnvOptions = {
|
||||||
ports: PortConfig;
|
ports: PortConfig;
|
||||||
/** Include MCP URL environment variables (used in dev/start modes) */
|
|
||||||
includeMcp?: boolean;
|
|
||||||
/** Set NODE_ENV to production */
|
/** Set NODE_ENV to production */
|
||||||
production?: boolean;
|
production?: boolean;
|
||||||
/** Enable debug mode (NEXT_PUBLIC_KANDEV_DEBUG) */
|
/** Enable debug mode (NEXT_PUBLIC_KANDEV_DEBUG) */
|
||||||
@@ -91,25 +90,18 @@ export type WebEnvOptions = {
|
|||||||
* @returns Environment object for the web process
|
* @returns Environment object for the web process
|
||||||
*/
|
*/
|
||||||
export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv {
|
export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv {
|
||||||
const { ports, includeMcp = false, production = false, debug = false } = options;
|
const { ports, production = false, debug = false } = options;
|
||||||
|
|
||||||
// Server-side env vars use localhost (SSR runs on same machine as backend)
|
|
||||||
// Client-side uses NEXT_PUBLIC_*_PORT to build URLs dynamically from window.location.hostname
|
|
||||||
// This allows accessing the app from any device (iPhone, Tailscale, etc.)
|
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
// Server-side: full localhost URL for SSR
|
// Server-side: full localhost URL for SSR fetches (Next.js SSR → Go backend)
|
||||||
KANDEV_API_BASE_URL: ports.backendUrl,
|
KANDEV_API_BASE_URL: ports.backendUrl,
|
||||||
// Client-side: only pass ports, client builds URL from current hostname
|
// Client-side: port injection for dev mode (browser on :3000, API on :8080).
|
||||||
|
// In production, the backend reverse-proxies Next.js so the client uses same-origin.
|
||||||
NEXT_PUBLIC_KANDEV_API_PORT: String(ports.backendPort),
|
NEXT_PUBLIC_KANDEV_API_PORT: String(ports.backendPort),
|
||||||
PORT: String(ports.webPort),
|
PORT: String(ports.webPort),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (includeMcp) {
|
|
||||||
env.KANDEV_MCP_SERVER_URL = ports.mcpUrl;
|
|
||||||
env.NEXT_PUBLIC_KANDEV_MCP_PORT = String(ports.mcpPort);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (production) {
|
if (production) {
|
||||||
(env as Record<string, string>).NODE_ENV = "production";
|
(env as Record<string, string>).NODE_ENV = "production";
|
||||||
}
|
}
|
||||||
@@ -127,8 +119,6 @@ export type StartupInfoOptions = {
|
|||||||
ports: PortConfig;
|
ports: PortConfig;
|
||||||
/** Database file path */
|
/** Database file path */
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
/** Whether to log MCP-related ports */
|
|
||||||
includeMcp?: boolean;
|
|
||||||
/** Log level being used */
|
/** Log level being used */
|
||||||
logLevel?: string;
|
logLevel?: string;
|
||||||
};
|
};
|
||||||
@@ -137,15 +127,12 @@ export type StartupInfoOptions = {
|
|||||||
* Logs a unified startup info block to the console.
|
* Logs a unified startup info block to the console.
|
||||||
*/
|
*/
|
||||||
export function logStartupInfo(options: StartupInfoOptions): void {
|
export function logStartupInfo(options: StartupInfoOptions): void {
|
||||||
const { header, ports, dbPath, includeMcp = false, logLevel } = options;
|
const { header, ports, dbPath, logLevel } = options;
|
||||||
console.log(`[kandev] ${header}`);
|
console.log(`[kandev] ${header}`);
|
||||||
console.log("[kandev] backend port:", ports.backendPort);
|
console.log("[kandev] url: http://localhost:" + ports.backendPort);
|
||||||
console.log("[kandev] web port:", ports.webPort);
|
|
||||||
console.log("[kandev] agentctl port:", ports.agentctlPort);
|
console.log("[kandev] agentctl port:", ports.agentctlPort);
|
||||||
if (includeMcp) {
|
console.log("[kandev] mcp port:", ports.mcpPort);
|
||||||
console.log("[kandev] mcp port:", ports.mcpPort);
|
console.log("[kandev] mcp url:", ports.mcpUrl);
|
||||||
console.log("[kandev] mcp url:", ports.mcpUrl);
|
|
||||||
}
|
|
||||||
if (dbPath) {
|
if (dbPath) {
|
||||||
console.log("[kandev] db path:", dbPath);
|
console.log("[kandev] db path:", dbPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,13 +121,12 @@ export async function runStart({
|
|||||||
...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true" } : {}),
|
...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true" } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const webEnv = buildWebEnv({ ports, includeMcp: true, production: true, debug });
|
const webEnv = buildWebEnv({ ports, production: true, debug });
|
||||||
|
|
||||||
logStartupInfo({
|
logStartupInfo({
|
||||||
header: "start mode: using local build",
|
header: "start mode: using local build",
|
||||||
ports,
|
ports,
|
||||||
dbPath,
|
dbPath,
|
||||||
includeMcp: true,
|
|
||||||
logLevel,
|
logLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,6 +167,6 @@ export async function runStart({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
|
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
|
||||||
console.log(`[kandev] web ready at ${webUrl}`);
|
console.log("[kandev] ready at " + ports.backendUrl);
|
||||||
openBrowser(webUrl);
|
openBrowser(ports.backendUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-26
@@ -24,31 +24,15 @@ export const viewport: Viewport = {
|
|||||||
userScalable: false,
|
userScalable: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract port from URL string (e.g., "http://localhost:8080" -> "8080")
|
|
||||||
*/
|
|
||||||
function extractPort(url: string): string | null {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return parsed.port || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
// Runtime URL configuration for the browser client.
|
// API port injection for dev mode (browser opens at web port, API on backend port).
|
||||||
// KANDEV_PUBLIC_URL: full external URL when behind a reverse proxy / ingress
|
// In production single-port mode, this is not needed — the client uses
|
||||||
// (e.g., "https://kandev.example.com") — injected as window.__KANDEV_API_BASE_URL.
|
// window.location.origin (same-origin, works for any domain / reverse proxy).
|
||||||
// Otherwise: extract ports so client builds URLs from window.location.hostname,
|
const apiPort = process.env.NEXT_PUBLIC_KANDEV_API_PORT ?? null;
|
||||||
// allowing access from any device (iPhone, Tailscale, etc.)
|
|
||||||
const publicUrl = process.env.KANDEV_PUBLIC_URL?.replace(/\/$/, "") || null;
|
|
||||||
const apiPort = publicUrl ? null : extractPort(process.env.KANDEV_API_BASE_URL ?? "");
|
|
||||||
const mcpPort = extractPort(process.env.KANDEV_MCP_SERVER_URL ?? "");
|
|
||||||
const debugMode = process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true";
|
const debugMode = process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,16 +41,11 @@ export default function RootLayout({
|
|||||||
<meta name="apple-mobile-web-app-title" content="KanDev" />
|
<meta name="apple-mobile-web-app-title" content="KanDev" />
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased font-sans">
|
<body className="antialiased font-sans">
|
||||||
{publicUrl || apiPort || mcpPort || debugMode ? (
|
{apiPort || debugMode ? (
|
||||||
// Inject runtime config for production bundles.
|
|
||||||
// publicUrl: full base URL for reverse-proxy deployments (same-origin API)
|
|
||||||
// ports: client builds URLs using window.location.hostname + port
|
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: [
|
__html: [
|
||||||
publicUrl ? `window.__KANDEV_API_BASE_URL = ${JSON.stringify(publicUrl)};` : "",
|
|
||||||
apiPort ? `window.__KANDEV_API_PORT = ${JSON.stringify(apiPort)};` : "",
|
apiPort ? `window.__KANDEV_API_PORT = ${JSON.stringify(apiPort)};` : "",
|
||||||
mcpPort ? `window.__KANDEV_MCP_PORT = ${JSON.stringify(mcpPort)};` : "",
|
|
||||||
debugMode ? `window.__KANDEV_DEBUG = true;` : "",
|
debugMode ? `window.__KANDEV_DEBUG = true;` : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -69,21 +69,28 @@ function initTerminalInstance(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
log("Initial fit failed:", e);
|
log("Initial fit failed:", e);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const webglAddon = new WebglAddon();
|
|
||||||
webglAddon.onContextLoss(() => {
|
|
||||||
log("WebGL context lost");
|
|
||||||
webglAddon.dispose();
|
|
||||||
refs.webglAddonRef.current = null;
|
|
||||||
});
|
|
||||||
terminal.loadAddon(webglAddon);
|
|
||||||
refs.webglAddonRef.current = webglAddon;
|
|
||||||
log("WebGL addon loaded");
|
|
||||||
} catch (e) {
|
|
||||||
log("WebGL failed, using canvas:", e);
|
|
||||||
}
|
|
||||||
refs.xtermRef.current = terminal;
|
refs.xtermRef.current = terminal;
|
||||||
refs.fitAddonRef.current = fitAddon;
|
refs.fitAddonRef.current = fitAddon;
|
||||||
|
// Defer WebGL addon loading to the next animation frame so the initial
|
||||||
|
// synchronous work (Terminal + FitAddon + open) stays within the browser's
|
||||||
|
// frame budget and avoids "Violation: 'setTimeout' handler took Xms" warnings.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const term = refs.xtermRef.current;
|
||||||
|
if (!term || refs.webglAddonRef.current) return;
|
||||||
|
try {
|
||||||
|
const webglAddon = new WebglAddon();
|
||||||
|
webglAddon.onContextLoss(() => {
|
||||||
|
log("WebGL context lost");
|
||||||
|
webglAddon.dispose();
|
||||||
|
refs.webglAddonRef.current = null;
|
||||||
|
});
|
||||||
|
term.loadAddon(webglAddon);
|
||||||
|
refs.webglAddonRef.current = webglAddon;
|
||||||
|
log("WebGL addon loaded");
|
||||||
|
} catch (e) {
|
||||||
|
log("WebGL failed, using canvas:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
// Expose buffer reader on the container for e2e tests (xterm renders to
|
// Expose buffer reader on the container for e2e tests (xterm renders to
|
||||||
// canvas so text isn't accessible in the DOM).
|
// canvas so text isn't accessible in the DOM).
|
||||||
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
|
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
|
||||||
@@ -278,7 +285,12 @@ function connectWebSocket({
|
|||||||
attachAddonRef.current = null;
|
attachAddonRef.current = null;
|
||||||
}
|
}
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.close();
|
if (
|
||||||
|
wsRef.current.readyState === WebSocket.OPEN ||
|
||||||
|
wsRef.current.readyState === WebSocket.CLOSING
|
||||||
|
) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
const wsUrl = buildWsUrl(wsBaseUrl, sessionId, mode, terminalId, label);
|
const wsUrl = buildWsUrl(wsBaseUrl, sessionId, mode, terminalId, label);
|
||||||
@@ -402,7 +414,15 @@ export function useWebSocketConnection({
|
|||||||
attachAddonRef.current = null;
|
attachAddonRef.current = null;
|
||||||
}
|
}
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.close();
|
// Only close if the connection is actually open or has completed
|
||||||
|
// opening. Closing a CONNECTING WebSocket triggers a browser warning
|
||||||
|
// ("WebSocket is closed before the connection is established").
|
||||||
|
if (
|
||||||
|
wsRef.current.readyState === WebSocket.OPEN ||
|
||||||
|
wsRef.current.readyState === WebSocket.CLOSING
|
||||||
|
) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
|
|||||||
env: {
|
env: {
|
||||||
...(process.env as unknown as Record<string, string>),
|
...(process.env as unknown as Record<string, string>),
|
||||||
KANDEV_API_BASE_URL: baseUrl,
|
KANDEV_API_BASE_URL: baseUrl,
|
||||||
|
NEXT_PUBLIC_KANDEV_API_PORT: String(backendPort),
|
||||||
PORT: String(frontendPort),
|
PORT: String(frontendPort),
|
||||||
HOSTNAME: "localhost",
|
HOSTNAME: "localhost",
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
|
|||||||
@@ -106,11 +106,11 @@ export { expect } from "@playwright/test";
|
|||||||
async function setupPage(page: Page, backend: BackendContext, seedData: SeedData): Promise<void> {
|
async function setupPage(page: Page, backend: BackendContext, seedData: SeedData): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({
|
({
|
||||||
backendUrl,
|
backendPort,
|
||||||
repositoryId,
|
repositoryId,
|
||||||
agentProfileId,
|
agentProfileId,
|
||||||
}: {
|
}: {
|
||||||
backendUrl: string;
|
backendPort: string;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
agentProfileId: string;
|
agentProfileId: string;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -121,10 +121,11 @@ async function setupPage(page: Page, backend: BackendContext, seedData: SeedData
|
|||||||
localStorage.setItem("kandev.dialog.lastAgentProfileId", JSON.stringify(agentProfileId));
|
localStorage.setItem("kandev.dialog.lastAgentProfileId", JSON.stringify(agentProfileId));
|
||||||
localStorage.setItem("kandev.dialog.lastBranch", JSON.stringify("main"));
|
localStorage.setItem("kandev.dialog.lastBranch", JSON.stringify("main"));
|
||||||
// Set the window global that getBackendConfig() reads for API/WS connections
|
// Set the window global that getBackendConfig() reads for API/WS connections
|
||||||
window.__KANDEV_API_BASE_URL = backendUrl;
|
// (e2e tests run frontend and backend on separate ports, like dev mode)
|
||||||
|
window.__KANDEV_API_PORT = backendPort;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backendUrl: backend.baseUrl,
|
backendPort: String(backend.port),
|
||||||
repositoryId: seedData.repositoryId,
|
repositoryId: seedData.repositoryId,
|
||||||
agentProfileId: seedData.agentProfileId,
|
agentProfileId: seedData.agentProfileId,
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+1
-5
@@ -2,12 +2,8 @@ export {};
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
// Full URL overrides (for custom domains/proxies)
|
// Port injection for dev mode (browser on web port, API on backend port)
|
||||||
__KANDEV_API_BASE_URL?: string;
|
|
||||||
__KANDEV_MCP_SERVER_URL?: string;
|
|
||||||
// Port-only injection (client builds URL from window.location.hostname + port)
|
|
||||||
__KANDEV_API_PORT?: string;
|
__KANDEV_API_PORT?: string;
|
||||||
__KANDEV_MCP_PORT?: string;
|
|
||||||
// Debug mode flag (injected at runtime by layout.tsx)
|
// Debug mode flag (injected at runtime by layout.tsx)
|
||||||
__KANDEV_DEBUG?: boolean;
|
__KANDEV_DEBUG?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-48
@@ -1,68 +1,33 @@
|
|||||||
export type AppConfig = {
|
export type AppConfig = {
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
mcpServerUrl: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default ports
|
// Server-side default (used during SSR - always localhost since SSR runs on same machine)
|
||||||
const DEFAULT_API_PORT = 8080;
|
const DEFAULT_API_BASE_URL = "http://localhost:8080";
|
||||||
const DEFAULT_MCP_PORT = 9090;
|
|
||||||
|
|
||||||
// Server-side defaults (used during SSR - always localhost since SSR runs on same machine)
|
|
||||||
const DEFAULT_API_BASE_URL = `http://localhost:${DEFAULT_API_PORT}`;
|
|
||||||
const DEFAULT_MCP_SERVER_URL = `http://localhost:${DEFAULT_MCP_PORT}/sse`;
|
|
||||||
|
|
||||||
export const DEBUG_UI =
|
export const DEBUG_UI =
|
||||||
process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true" ||
|
process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true" ||
|
||||||
(typeof window !== "undefined" && window.__KANDEV_DEBUG === true);
|
(typeof window !== "undefined" && window.__KANDEV_DEBUG === true);
|
||||||
|
|
||||||
/**
|
|
||||||
* Build URL from current page hostname and port.
|
|
||||||
* This allows accessing the app from any device (iPhone, Tailscale, etc.)
|
|
||||||
* without manual configuration.
|
|
||||||
*/
|
|
||||||
function buildClientUrl(port: number, path = ""): string {
|
|
||||||
const protocol = window.location.protocol === "https:" ? "https:" : "http:";
|
|
||||||
return `${protocol}//${window.location.hostname}:${port}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBackendConfig(): AppConfig {
|
export function getBackendConfig(): AppConfig {
|
||||||
// Server-side: use env vars or localhost defaults (SSR runs on same machine as backend)
|
// Server-side: use env vars or localhost defaults (SSR runs on same machine as backend)
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return {
|
return {
|
||||||
apiBaseUrl: process.env.KANDEV_API_BASE_URL ?? DEFAULT_API_BASE_URL,
|
apiBaseUrl: process.env.KANDEV_API_BASE_URL ?? DEFAULT_API_BASE_URL,
|
||||||
mcpServerUrl: process.env.KANDEV_MCP_SERVER_URL ?? DEFAULT_MCP_SERVER_URL,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side: Build URLs from current hostname + port
|
// Client-side URL resolution:
|
||||||
// Priority:
|
// 1. Port-based URL via __KANDEV_API_PORT (dev mode: browser on :3000, API on :8080)
|
||||||
// 1. Explicit full URL override via window.__KANDEV_API_BASE_URL (for custom domains/proxies)
|
// 2. Same-origin (production: Go reverse-proxies Next.js on single port)
|
||||||
// 2. Port from window.__KANDEV_API_PORT (injected by layout.tsx from server env)
|
// Works for any hosting scenario: localhost, custom domain, Tailscale, etc.
|
||||||
// 3. Port from NEXT_PUBLIC env var (build-time)
|
if (window.__KANDEV_API_PORT) {
|
||||||
// 4. Default port
|
const port = parseInt(window.__KANDEV_API_PORT, 10);
|
||||||
// All port-based options build URL from window.location.hostname
|
if (Number.isInteger(port) && port > 0 && port <= 65535) {
|
||||||
|
const protocol = window.location.protocol;
|
||||||
if (window.__KANDEV_API_BASE_URL) {
|
return { apiBaseUrl: `${protocol}//${window.location.hostname}:${port}` };
|
||||||
// Full URL override - use as-is (for custom domains/proxies)
|
}
|
||||||
return {
|
|
||||||
apiBaseUrl: window.__KANDEV_API_BASE_URL,
|
|
||||||
mcpServerUrl: window.__KANDEV_MCP_SERVER_URL || buildClientUrl(DEFAULT_MCP_PORT, "/sse"),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build URLs dynamically from current hostname + port
|
return { apiBaseUrl: window.location.origin };
|
||||||
const apiPort =
|
|
||||||
parseInt(window.__KANDEV_API_PORT || "", 10) ||
|
|
||||||
parseInt(process.env.NEXT_PUBLIC_KANDEV_API_PORT || "", 10) ||
|
|
||||||
DEFAULT_API_PORT;
|
|
||||||
|
|
||||||
const mcpPort =
|
|
||||||
parseInt(window.__KANDEV_MCP_PORT || "", 10) ||
|
|
||||||
parseInt(process.env.NEXT_PUBLIC_KANDEV_MCP_PORT || "", 10) ||
|
|
||||||
DEFAULT_MCP_PORT;
|
|
||||||
|
|
||||||
return {
|
|
||||||
apiBaseUrl: buildClientUrl(apiPort),
|
|
||||||
mcpServerUrl: buildClientUrl(mcpPort, "/sse"),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
vi.mock("@/lib/config", () => ({
|
vi.mock("@/lib/config", () => ({
|
||||||
getBackendConfig: () => ({
|
getBackendConfig: () => ({
|
||||||
apiBaseUrl: "http://localhost:8080",
|
apiBaseUrl: "http://localhost:8080",
|
||||||
mcpServerUrl: "http://localhost:9090/sse",
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Convert HTTP URL to WebSocket URL
|
* Convert HTTP URL to WebSocket URL.
|
||||||
* http://localhost:8080 → ws://localhost:8080/ws
|
* http://localhost:8080 → ws://localhost:8080/ws
|
||||||
* https://api.example.com → wss://api.example.com/ws
|
* https://api.example.com → wss://api.example.com/ws
|
||||||
*/
|
*/
|
||||||
|
|||||||
+14
-42
@@ -5,10 +5,10 @@ Run Kandev in a Docker container. For Kubernetes deployment, see [k8s.md](k8s.md
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 8080:8080 -p 3000:3000 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest
|
docker run -p 8080:8080 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:3000` in your browser.
|
Open `http://localhost:8080` in your browser.
|
||||||
|
|
||||||
## Using the Pre-built Image
|
## Using the Pre-built Image
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ Without a volume, data is lost when the container is removed.
|
|||||||
Configuration is done via `KANDEV_`-prefixed environment variables:
|
Configuration is done via `KANDEV_`-prefixed environment variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 8080:8080 -p 3000:3000 \
|
docker run -p 8080:8080 \
|
||||||
-v kandev-data:/data \
|
-v kandev-data:/data \
|
||||||
-e KANDEV_LOG_LEVEL=debug \
|
-e KANDEV_LOG_LEVEL=debug \
|
||||||
ghcr.io/kdlbs/kandev:latest
|
ghcr.io/kdlbs/kandev:latest
|
||||||
@@ -63,7 +63,6 @@ docker run -p 8080:8080 -p 3000:3000 \
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `KANDEV_PUBLIC_URL` | (unset) | External URL when behind a reverse proxy (e.g., `https://kandev.example.com`) |
|
|
||||||
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
|
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
|
||||||
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
|
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
|
||||||
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
|
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
|
||||||
@@ -76,7 +75,7 @@ docker run -p 8080:8080 -p 3000:3000 \
|
|||||||
To use PostgreSQL instead of SQLite:
|
To use PostgreSQL instead of SQLite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 8080:8080 -p 3000:3000 \
|
docker run -p 8080:8080 \
|
||||||
-e KANDEV_DATABASE_DRIVER=postgres \
|
-e KANDEV_DATABASE_DRIVER=postgres \
|
||||||
-e KANDEV_DATABASE_HOST=host.docker.internal \
|
-e KANDEV_DATABASE_HOST=host.docker.internal \
|
||||||
-e KANDEV_DATABASE_PORT=5432 \
|
-e KANDEV_DATABASE_PORT=5432 \
|
||||||
@@ -86,20 +85,21 @@ docker run -p 8080:8080 -p 3000:3000 \
|
|||||||
ghcr.io/kdlbs/kandev:latest
|
ghcr.io/kdlbs/kandev:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ports
|
## Port
|
||||||
|
|
||||||
|
Kandev exposes a single port. The Go backend serves the API, WebSocket, and reverse-proxies the Next.js frontend — all on one port:
|
||||||
|
|
||||||
| Port | Service |
|
| Port | Service |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `8080` | Backend API + WebSocket |
|
| `8080` | API + WebSocket + Web UI |
|
||||||
| `3000` | Web UI |
|
|
||||||
|
|
||||||
Override ports by changing the published ports and passing CLI flags:
|
Override the port:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 9080:9080 -p 4000:4000 \
|
docker run -p 9080:9080 \
|
||||||
-v kandev-data:/data \
|
-v kandev-data:/data \
|
||||||
ghcr.io/kdlbs/kandev:latest \
|
ghcr.io/kdlbs/kandev:latest \
|
||||||
kandev start --backend-port 9080 --web-port 4000
|
kandev start --backend-port 9080
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker Compose
|
## Docker Compose
|
||||||
@@ -112,7 +112,6 @@ services:
|
|||||||
image: ghcr.io/kdlbs/kandev:latest
|
image: ghcr.io/kdlbs/kandev:latest
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
volumes:
|
||||||
- kandev-data:/data
|
- kandev-data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -133,7 +132,6 @@ services:
|
|||||||
image: ghcr.io/kdlbs/kandev:latest
|
image: ghcr.io/kdlbs/kandev:latest
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
volumes:
|
||||||
- kandev-data:/data
|
- kandev-data:/data
|
||||||
environment:
|
environment:
|
||||||
@@ -170,20 +168,7 @@ volumes:
|
|||||||
|
|
||||||
## Reverse Proxy
|
## Reverse Proxy
|
||||||
|
|
||||||
When running Kandev behind a reverse proxy (nginx, Traefik, Caddy) that serves both the API and web UI on the same domain, set `KANDEV_PUBLIC_URL` so the frontend routes API calls through the proxy instead of trying to reach port 8080 directly:
|
Since Kandev serves everything on a single port, a reverse proxy only needs to forward all traffic to port 8080. No extra environment variables are needed — the frontend automatically uses `window.location.origin` to reach the API.
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -p 8080:8080 -p 3000:3000 \
|
|
||||||
-v kandev-data:/data \
|
|
||||||
-e KANDEV_PUBLIC_URL=https://kandev.example.com \
|
|
||||||
ghcr.io/kdlbs/kandev:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Your reverse proxy should route:
|
|
||||||
- `/api/*` and `/ws` → `http://kandev:8080` (backend)
|
|
||||||
- `/*` → `http://kandev:3000` (web UI)
|
|
||||||
|
|
||||||
With WebSocket support enabled (for `/ws`).
|
|
||||||
|
|
||||||
### Docker Compose with Caddy
|
### Docker Compose with Caddy
|
||||||
|
|
||||||
@@ -193,8 +178,6 @@ services:
|
|||||||
image: ghcr.io/kdlbs/kandev:latest
|
image: ghcr.io/kdlbs/kandev:latest
|
||||||
volumes:
|
volumes:
|
||||||
- kandev-data:/data
|
- kandev-data:/data
|
||||||
environment:
|
|
||||||
KANDEV_PUBLIC_URL: "https://kandev.example.com"
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
@@ -216,18 +199,7 @@ Example `Caddyfile`:
|
|||||||
|
|
||||||
```
|
```
|
||||||
kandev.example.com {
|
kandev.example.com {
|
||||||
handle /api/* {
|
reverse_proxy kandev:8080
|
||||||
reverse_proxy kandev:8080
|
|
||||||
}
|
|
||||||
handle /ws {
|
|
||||||
reverse_proxy kandev:8080
|
|
||||||
}
|
|
||||||
handle /health {
|
|
||||||
reverse_proxy kandev:8080
|
|
||||||
}
|
|
||||||
handle {
|
|
||||||
reverse_proxy kandev:3000
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -236,7 +208,7 @@ kandev.example.com {
|
|||||||
By default, `KANDEV_DOCKER_ENABLED=false` inside the container. To enable Docker-based agent execution, mount the Docker socket:
|
By default, `KANDEV_DOCKER_ENABLED=false` inside the container. To enable Docker-based agent execution, mount the Docker socket:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 8080:8080 -p 3000:3000 \
|
docker run -p 8080:8080 \
|
||||||
-v kandev-data:/data \
|
-v kandev-data:/data \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-e KANDEV_DOCKER_ENABLED=true \
|
-e KANDEV_DOCKER_ENABLED=true \
|
||||||
|
|||||||
+7
-15
@@ -56,7 +56,7 @@ kubectl logs -l app=kandev -f
|
|||||||
| Resource | File | Purpose |
|
| Resource | File | Purpose |
|
||||||
|----------|------|---------|
|
|----------|------|---------|
|
||||||
| Deployment | `deployment.yaml` | Single-replica pod running backend + web |
|
| Deployment | `deployment.yaml` | Single-replica pod running backend + web |
|
||||||
| Service | `service.yaml` | ClusterIP exposing ports 8080 (API) and 3000 (UI) |
|
| Service | `service.yaml` | ClusterIP exposing port 8080 |
|
||||||
| ConfigMap | `configmap.yaml` | Non-sensitive environment configuration |
|
| ConfigMap | `configmap.yaml` | Non-sensitive environment configuration |
|
||||||
| PVC | `pvc.yaml` | 10Gi persistent volume for SQLite + worktrees |
|
| PVC | `pvc.yaml` | 10Gi persistent volume for SQLite + worktrees |
|
||||||
| Ingress | `ingress.yaml` | Example ingress with WebSocket support |
|
| Ingress | `ingress.yaml` | Example ingress with WebSocket support |
|
||||||
@@ -66,22 +66,15 @@ kubectl logs -l app=kandev -f
|
|||||||
**Port-forward** (quickest for testing):
|
**Port-forward** (quickest for testing):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl port-forward svc/kandev 3000:3000 8080:8080
|
kubectl port-forward svc/kandev 8080:8080
|
||||||
# Open http://localhost:3000
|
# Open http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ingress**: Edit `k8s/ingress.yaml` to set your domain, then apply. The ingress routes `/api` and `/ws` to the backend (port 8080) and everything else to the web UI (port 3000).
|
**Ingress**: Edit `k8s/ingress.yaml` to set your domain, then apply. The ingress routes all traffic to the backend on port 8080 (the Go backend reverse-proxies the Next.js frontend internally).
|
||||||
|
|
||||||
### Custom Domain / Reverse Proxy
|
### Custom Domain / Reverse Proxy
|
||||||
|
|
||||||
When serving Kandev behind an ingress with a custom domain (e.g., `https://kandev.example.com`), set `KANDEV_PUBLIC_URL` so the frontend knows to use the ingress URL instead of trying to reach the backend on port 8080 directly:
|
No extra configuration is needed. The frontend automatically uses `window.location.origin` to reach the API, which works with any domain, reverse proxy, or ingress setup.
|
||||||
|
|
||||||
```yaml
|
|
||||||
# In k8s/configmap.yaml
|
|
||||||
KANDEV_PUBLIC_URL: "https://kandev.example.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
Without this, the browser tries to connect to `https://kandev.example.com:8080/api/...` which doesn't exist — only the ingress port (443) is exposed externally.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -91,8 +84,7 @@ Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper).
|
|||||||
|
|
||||||
| Env Var | Default | Description |
|
| Env Var | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `KANDEV_PUBLIC_URL` | (unset) | External URL when behind a reverse proxy (e.g., `https://kandev.example.com`) |
|
| `KANDEV_SERVER_PORT` | `8080` | Server port (API + WebSocket + Web UI) |
|
||||||
| `KANDEV_SERVER_PORT` | `8080` | Backend API port |
|
|
||||||
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
|
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
|
||||||
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
|
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
|
||||||
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
|
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
|
||||||
@@ -155,7 +147,7 @@ spec:
|
|||||||
|
|
||||||
## Health Checks
|
## Health Checks
|
||||||
|
|
||||||
The deployment includes both probes on the backend `/health` endpoint:
|
The deployment includes both probes on the `/health` endpoint:
|
||||||
|
|
||||||
- **Liveness probe**: Restarts the pod if the backend becomes unresponsive (30s interval, 3 failures)
|
- **Liveness probe**: Restarts the pod if the backend becomes unresponsive (30s interval, 3 failures)
|
||||||
- **Readiness probe**: Removes the pod from service during startup or issues (10s interval, 3 failures)
|
- **Readiness probe**: Removes the pod from service during startup or issues (10s interval, 3 failures)
|
||||||
|
|||||||
@@ -122,9 +122,9 @@ Extends the backend fixture with:
|
|||||||
|
|
||||||
- **`apiClient`** (worker-scoped) — HTTP client for seeding data via the backend API
|
- **`apiClient`** (worker-scoped) — HTTP client for seeding data via the backend API
|
||||||
- **`seedData`** (worker-scoped) — creates a workspace, discovers the default workflow and its steps
|
- **`seedData`** (worker-scoped) — creates a workspace, discovers the default workflow and its steps
|
||||||
- **`testPage`** (per-test) — Playwright page with a browser context whose `baseURL` points to the worker's frontend, plus `localStorage` pre-configured:
|
- **`testPage`** (per-test) — Playwright page with a browser context whose `baseURL` points to the worker's frontend, pre-configured with:
|
||||||
- `kandev.onboarding.completed = "true"` — skips onboarding
|
- `kandev.onboarding.completed = "true"` in localStorage — skips onboarding
|
||||||
- `window.__KANDEV_API_BASE_URL` — points to the worker's backend (client-side override)
|
- `window.__KANDEV_API_PORT` injected via `addInitScript` — points to the worker's backend port
|
||||||
|
|
||||||
### API client (`helpers/api-client.ts`)
|
### API client (`helpers/api-client.ts`)
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,3 @@ data:
|
|||||||
|
|
||||||
# Logging — auto-detects K8s and uses JSON format
|
# Logging — auto-detects K8s and uses JSON format
|
||||||
KANDEV_LOG_LEVEL: info
|
KANDEV_LOG_LEVEL: info
|
||||||
|
|
||||||
# Public URL — set when behind a reverse proxy / ingress that serves both
|
|
||||||
# web and API on the same domain. Without this, the UI tries to reach the
|
|
||||||
# backend API on port 8080 directly, which fails behind a proxy.
|
|
||||||
# KANDEV_PUBLIC_URL: "https://kandev.example.com"
|
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ spec:
|
|||||||
- name: backend
|
- name: backend
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
- name: web
|
|
||||||
containerPort: 3000
|
|
||||||
protocol: TCP
|
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: kandev-config
|
name: kandev-config
|
||||||
|
|||||||
+1
-24
@@ -18,33 +18,10 @@ spec:
|
|||||||
- host: kandev.example.com
|
- host: kandev.example.com
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
# Backend API + WebSocket
|
|
||||||
- path: /api
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: kandev
|
|
||||||
port:
|
|
||||||
name: backend
|
|
||||||
- path: /ws
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: kandev
|
|
||||||
port:
|
|
||||||
name: backend
|
|
||||||
- path: /health
|
|
||||||
pathType: Exact
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: kandev
|
|
||||||
port:
|
|
||||||
name: backend
|
|
||||||
# Web UI (catch-all)
|
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: kandev
|
name: kandev
|
||||||
port:
|
port:
|
||||||
name: web
|
name: http
|
||||||
|
|||||||
+1
-5
@@ -9,11 +9,7 @@ spec:
|
|||||||
selector:
|
selector:
|
||||||
app: kandev
|
app: kandev
|
||||||
ports:
|
ports:
|
||||||
- name: backend
|
- name: http
|
||||||
port: 8080
|
port: 8080
|
||||||
targetPort: backend
|
targetPort: backend
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
- name: web
|
|
||||||
port: 3000
|
|
||||||
targetPort: web
|
|
||||||
protocol: TCP
|
|
||||||
|
|||||||
Reference in New Issue
Block a user