diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index 1eb4be35..5f0ee2be 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -16,7 +16,7 @@ const CLIENT_DISCONNECT_ERROR_CODES = new Set([ ]); const CLIENT_DISCONNECT_ERROR_PATTERN = /\b(?:socket hang up|client disconnected|premature close)\b/iu; const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-handler-installed"); -const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]); +const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/opencode", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]); const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u; const TRACE_EXPLORER_TEMPLATE_TOKEN = "{trace_id}"; const TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_:-]+)\}/gu; @@ -131,9 +131,18 @@ function runtimeConfigFromEnv() { const config = { displayTime: displayTimeConfigFromEnv() }; const workbench = workbenchRuntimeConfigFromEnv(); if (workbench) config.workbench = workbench; + const opencode = opencodeRuntimeConfigFromEnv(); + if (opencode) config.opencode = opencode; return config; } +function opencodeRuntimeConfigFromEnv() { + const url = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_URL"); + if (!url) return null; + validateOpencodeUrl(url); + return { url }; +} + function displayTimeConfigFromEnv() { const timeZone = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE"); const locale = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE"); @@ -151,6 +160,28 @@ function requiredRuntimeConfigEnv(name) { return value.trim(); } +function optionalRuntimeConfigEnv(name) { + const value = process.env[name]; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function validateOpencodeUrl(value) { + let url; + try { + url = new URL(value); + } catch { + throw new Error("invalid HWLAB_CLOUD_WEB_OPENCODE_URL: must be an absolute http(s) URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("invalid HWLAB_CLOUD_WEB_OPENCODE_URL: only http(s) URLs are allowed"); + } + if (url.username || url.password || url.hash) { + throw new Error("invalid HWLAB_CLOUD_WEB_OPENCODE_URL: credentials and fragments are not allowed"); + } +} + function validateDisplayTimeZone(timeZone) { try { new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0)); diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index 25900001..b2b22920 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -982,6 +982,7 @@ function secretNameForProfile(secretName, profile) { if (secretName === "hwlab-cloud-api-dev-db") return `hwlab-cloud-api-${profile}-db`; if (secretName === "hwlab-code-agent-provider") return `hwlab-${profile}-code-agent-provider`; if (secretName === "hwlab-code-agent-codex-auth") return `hwlab-${profile}-code-agent-codex-auth`; + if (secretName === "hwlab-opencode-server-auth") return `hwlab-${profile}-opencode-server-auth`; if (secretName === "hwlab-bootstrap-admin") return `hwlab-${profile}-bootstrap-admin`; return secretName; } @@ -5637,6 +5638,98 @@ function secretPlaneIssueLabelValue(issueRef) { return label; } +function opencodeServerManifest({ profile = "v03", source }) { + assert.ok(isRuntimeLane(profile), `opencode-server profile must be a runtime lane, got ${profile}`); + const namespace = namespaceNameForProfile(profile); + const name = "opencode-server"; + const selector = { + "app.kubernetes.io/name": name, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/environment": profile, + "hwlab.pikastech.local/profile": profile + }; + const labels = { + ...selector, + "hwlab.pikastech.local/component": "opencode", + "hwlab.pikastech.local/gitops-target": profile, + "hwlab.pikastech.local/service-id": name, + "hwlab.pikastech.local/source-commit": source.full + }; + const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" }; + const authSecretName = secretNameForProfile("hwlab-opencode-server-auth", profile); + const configContent = JSON.stringify({ autoupdate: false, share: "disabled" }); + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ServiceAccount", + metadata: { name, namespace, labels, annotations } + }, + { + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { name: `${name}-data`, namespace, labels, annotations }, + spec: { + accessModes: ["ReadWriteOnce"], + resources: { requests: { storage: "8Gi" } } + } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name, namespace, labels, annotations }, + spec: { + type: "ClusterIP", + selector, + ports: [{ name: "http", port: 4096, targetPort: "http" }] + } + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name, namespace, labels, annotations }, + spec: { + replicas: 1, + strategy: { type: "Recreate" }, + selector: { matchLabels: selector }, + template: { + metadata: { labels, annotations }, + spec: { + serviceAccountName: name, + securityContext: { fsGroup: 1000, fsGroupChangePolicy: "OnRootMismatch" }, + containers: [{ + name, + image: "ghcr.io/anomalyco/opencode:1.17.7", + imagePullPolicy: "IfNotPresent", + command: ["opencode"], + args: ["serve", "--hostname", "0.0.0.0", "--port", "4096"], + workingDir: "/workspace", + env: [ + { name: "HOME", value: "/workspace" }, + { name: "XDG_CONFIG_HOME", value: "/workspace/.config" }, + { name: "XDG_DATA_HOME", value: "/workspace/.local/share" }, + { name: "OPENCODE_CONFIG_CONTENT", value: configContent }, + { name: "OPENCODE_SERVER_USERNAME", valueFrom: { secretKeyRef: { name: authSecretName, key: "username" } } }, + { name: "OPENCODE_SERVER_PASSWORD", valueFrom: { secretKeyRef: { name: authSecretName, key: "password" } } } + ], + ports: [{ name: "http", containerPort: 4096 }], + startupProbe: { tcpSocket: { port: "http" }, periodSeconds: 5, failureThreshold: 60 }, + readinessProbe: { tcpSocket: { port: "http" }, periodSeconds: 10, failureThreshold: 3 }, + livenessProbe: { tcpSocket: { port: "http" }, periodSeconds: 20, failureThreshold: 3 }, + resources: { requests: { cpu: "100m", memory: "256Mi" }, limits: { cpu: "1", memory: "1Gi" } }, + volumeMounts: [{ name: "workspace", mountPath: "/workspace" }] + }], + volumes: [{ name: "workspace", persistentVolumeClaim: { claimName: `${name}-data` } }] + } + } + } + } + ] + }; +} + function requiredSecretPlaneString(value, label) { assert.equal(typeof value, "string", `${label} must be a string`); const trimmed = value.trim(); @@ -5653,7 +5746,7 @@ function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile == if (externalPostgres) resources.push("external-postgres.yaml"); else resources.push("postgres.yaml"); if (workbenchRedis) resources.push("workbench-redis.yaml"); - resources.push("openfga.yaml", "observability.yaml"); + resources.push("openfga.yaml", "observability.yaml", "opencode.yaml"); } if (includeDeviceAgent) resources.push("device-agent-71-freq.yaml"); resources.push("node-frpc.yaml"); @@ -5812,6 +5905,7 @@ async function plannedFiles(args) { if (workbenchRedis) putJson(`${runtimePath}/workbench-redis.yaml`, workbenchRuntimeRedisManifest({ profile, config: workbenchRedis, source })); if (isRuntimeLane(profile)) putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ profile, source })); if (isRuntimeLane(profile)) putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, profile, namespace, labels: profileLabels, annotations, metricsSidecarScript })); + if (isRuntimeLane(profile)) putJson(`${runtimePath}/opencode.yaml`, opencodeServerManifest({ profile, source })); if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages })); putJson(`${runtimePath}/node-frpc.yaml`, nodeFrpcManifest({ profile, source })); } diff --git a/web/hwlab-cloud-web/src/components/layout/AppShell.vue b/web/hwlab-cloud-web/src/components/layout/AppShell.vue index 8fd4b405..587efd69 100644 --- a/web/hwlab-cloud-web/src/components/layout/AppShell.vue +++ b/web/hwlab-cloud-web/src/components/layout/AppShell.vue @@ -32,7 +32,7 @@ interface NavSection { } const navSections = [ - { title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] }, + { title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "OpenCode", label: "OpenCode", path: "/opencode", icon: "O", navId: "opencode.root" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] }, { title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M", navId: "project.mdtodo" }] }, { title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", icon: "U", navId: "user.usage" }, { name: "Billing", label: "充值", path: "/billing", icon: "$", navId: "user.billing" }] }, { title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] }, diff --git a/web/hwlab-cloud-web/src/config/runtime.ts b/web/hwlab-cloud-web/src/config/runtime.ts index b053d19a..adf9018a 100644 --- a/web/hwlab-cloud-web/src/config/runtime.ts +++ b/web/hwlab-cloud-web/src/config/runtime.ts @@ -6,6 +6,10 @@ export interface DisplayTimeConfig { label: string; } +export interface OpenCodeFrameConfig { + url: string; +} + const DEFAULT_DISPLAY_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { year: "numeric", month: "2-digit", @@ -91,6 +95,14 @@ export function traceExplorerHref(traceId: string | null | undefined): string | } } +export function opencodeFrameUrl(): string | null { + const value = window.HWLAB_CLOUD_WEB_CONFIG?.opencode?.url; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!isSafeOpencodeFrameUrl(trimmed)) return null; + return trimmed; +} + function normalizedTraceId(value: string | null | undefined): string | null { const traceId = typeof value === "string" ? value.trim() : ""; return TRACE_ID_PATTERN.test(traceId) ? traceId : null; @@ -110,6 +122,16 @@ function isSafeTraceExplorerUrlTemplate(template: string): boolean { } } +function isSafeOpencodeFrameUrl(value: string): boolean { + if (!value || value.startsWith("//")) return false; + try { + const url = new URL(value, runtimeOrigin()); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + function runtimeOrigin(): string { return typeof window.location?.origin === "string" && window.location.origin.length > 0 ? window.location.origin diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts index 32f93e5a..5a4f7d2c 100644 --- a/web/hwlab-cloud-web/src/router/index.ts +++ b/web/hwlab-cloud-web/src/router/index.ts @@ -5,6 +5,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router" import { installRouterGuards } from "./guards"; const CodeWorkbenchView = () => import("@/views/workbench/CodeWorkbenchView.vue"); +const OpenCodeFrameView = () => import("@/views/opencode/OpenCodeFrameView.vue"); const ProjectsView = () => import("@/views/projects/ProjectsView.vue"); const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue"); @@ -15,6 +16,7 @@ const routes: RouteRecordRaw[] = [ { path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, navId: "user.dashboard", title: "平台概览", section: "user" } }, { path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } }, { path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } }, + { path: "/opencode", name: "OpenCode", component: OpenCodeFrameView, meta: { requiresAuth: true, navId: "opencode.root", title: "OpenCode", section: "opencode" } }, { path: "/projects", name: "Projects", component: ProjectsView, meta: { requiresAuth: true, navId: "project.overview", title: "项目", section: "project" } }, { path: "/projects/mdtodo", name: "ProjectMdtodo", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } }, { path: "/projects/mdtodo/sources/:sourceId/files/:fileRef", name: "ProjectMdtodoFile", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } }, diff --git a/web/hwlab-cloud-web/src/router/meta.d.ts b/web/hwlab-cloud-web/src/router/meta.d.ts index 4c891af9..c3a7cf8c 100644 --- a/web/hwlab-cloud-web/src/router/meta.d.ts +++ b/web/hwlab-cloud-web/src/router/meta.d.ts @@ -9,6 +9,6 @@ declare module "vue-router" { requiresAdmin?: boolean; navId?: string; title?: string; - section?: "workbench" | "project" | "admin" | "user" | "system"; + section?: "workbench" | "opencode" | "project" | "admin" | "user" | "system"; } } diff --git a/web/hwlab-cloud-web/src/stores/auth.ts b/web/hwlab-cloud-web/src/stores/auth.ts index e11d1883..000ee3ad 100644 --- a/web/hwlab-cloud-web/src/stores/auth.ts +++ b/web/hwlab-cloud-web/src/stores/auth.ts @@ -133,6 +133,7 @@ function accessFromUnknown(value: unknown): AuthAccess | undefined { function firstAllowedNavPath(allowedIds: string[]): string { const ordered = [ { id: "workbench.code", path: "/workbench" }, + { id: "opencode.root", path: "/opencode" }, { id: "project.mdtodo", path: "/projects/mdtodo" }, { id: "user.dashboard", path: "/dashboard" }, { id: "project.overview", path: "/projects" }, diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts index 31cf2d06..28441ba5 100644 --- a/web/hwlab-cloud-web/src/types/global.d.ts +++ b/web/hwlab-cloud-web/src/types/global.d.ts @@ -14,6 +14,9 @@ declare global { }; traceExplorerUrlTemplate?: string; }; + opencode?: { + url?: string; + }; }; __HWLAB_WORKBENCH_UI_TRACE__?: { uiTraceId: string; diff --git a/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue b/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue new file mode 100644 index 00000000..eeb8b454 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/opencode/OpenCodeFrameView.vue @@ -0,0 +1,53 @@ + + +