From dac5a14e114cde5c8c7b1877dfd5527d01eb7af9 Mon Sep 17 00:00:00 2001 From: louiscsq Date: Fri, 17 Apr 2026 10:26:36 +1000 Subject: [PATCH 01/13] Add cross-workspace deployment UI and catalog remapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: - Add collapsible Deployment section to OptimizationConfig with target workspace URL, target space ID, and catalog mapping (source→target) - Extend GSOTriggerRequest with deploy_space_id and catalog_map fields Backend: - Extend TriggerRequest with deploy_space_id and catalog_map - Forward new params through trigger → job_launcher → job parameters GSO: - Add catalog_map widget to run_cross_env_deploy notebook - Remap table/metric_view identifiers before applying to target workspace (e.g. dev_catalog.schema.table → prod_catalog.schema.table) - Forward deploy_space_id and catalog_map through trigger and job_launcher Co-authored-by: Isaac --- backend/routers/auto_optimize.py | 4 + .../auto-optimize/OptimizationConfig.tsx | 109 +++++++++++++++++- frontend/src/types/index.ts | 2 + .../backend/job_launcher.py | 4 + .../integration/trigger.py | 4 + .../jobs/run_cross_env_deploy.py | 25 ++++ 6 files changed, 147 insertions(+), 1 deletion(-) diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py index 1b5bad49b..d9e76f0e4 100644 --- a/backend/routers/auto_optimize.py +++ b/backend/routers/auto_optimize.py @@ -61,6 +61,8 @@ class TriggerRequest(BaseModel): apply_mode: str = "genie_config" levers: list[int] | None = None deploy_target: str | None = None + deploy_space_id: str | None = None + catalog_map: dict[str, str] | None = None class SchemaAccessStatus(BaseModel): @@ -857,6 +859,8 @@ async def trigger(body: TriggerRequest, request: Request): apply_mode=body.apply_mode, levers=body.levers, deploy_target=body.deploy_target, + deploy_space_id=body.deploy_space_id, + catalog_map=body.catalog_map, ) return { "runId": result.run_id, diff --git a/frontend/src/components/auto-optimize/OptimizationConfig.tsx b/frontend/src/components/auto-optimize/OptimizationConfig.tsx index 4ea4de597..686737467 100644 --- a/frontend/src/components/auto-optimize/OptimizationConfig.tsx +++ b/frontend/src/components/auto-optimize/OptimizationConfig.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { AlertTriangle, Rocket } from "lucide-react" +import { AlertTriangle, ChevronDown, ChevronRight, Plus, Rocket, Trash2, Upload } from "lucide-react" import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" import { triggerAutoOptimize } from "@/lib/api" @@ -33,6 +33,12 @@ export function OptimizationConfig({ spaceId, onStarted, onTriggerStart, onTrigg const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + // Deployment config + const [deployExpanded, setDeployExpanded] = useState(false) + const [deployTarget, setDeployTarget] = useState("") + const [deploySpaceId, setDeploySpaceId] = useState("") + const [catalogMappings, setCatalogMappings] = useState<{ source: string; target: string }[]>([]) + const hasHealthIssues = (healthIssues?.length ?? 0) > 0 const canStart = permissions?.can_start === true && !hasHealthIssues @@ -50,10 +56,18 @@ export function OptimizationConfig({ spaceId, onStarted, onTriggerStart, onTrigg setError(null) onTriggerStart?.() try { + const catalogMap = catalogMappings.reduce>((acc, m) => { + if (m.source.trim() && m.target.trim()) acc[m.source.trim()] = m.target.trim() + return acc + }, {}) + const result = await triggerAutoOptimize({ space_id: spaceId, apply_mode: applyMode, levers: Array.from(selectedLevers).sort(), + deploy_target: deployTarget.trim() || undefined, + deploy_space_id: deploySpaceId.trim() || undefined, + catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined, }) onStarted(result.runId) } catch (e) { @@ -104,6 +118,99 @@ export function OptimizationConfig({ spaceId, onStarted, onTriggerStart, onTrigg + {/* Deployment (collapsible) */} +
+ + {deployExpanded && ( +
+

+ After optimization, deploy the optimized config to a target workspace. Leave blank to skip deployment. +

+ +
+ + setDeployTarget(e.target.value)} + placeholder="https://my-prod-workspace.cloud.databricks.com" + className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+ +
+ + setDeploySpaceId(e.target.value)} + placeholder="01f1347d7f1516ceaea7e5853166498f" + className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+ +
+
+ + +
+ {catalogMappings.map((m, i) => ( +
+ { + const next = [...catalogMappings] + next[i] = { ...next[i], source: e.target.value } + setCatalogMappings(next) + }} + placeholder="dev_catalog" + className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> + + { + const next = [...catalogMappings] + next[i] = { ...next[i], target: e.target.value } + setCatalogMappings(next) + }} + placeholder="prod_catalog" + className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> + +
+ ))} + {catalogMappings.length === 0 && ( +

No catalog mappings — table references will be used as-is

+ )} +
+
+ )} +
+ {/* Health Issues */} {hasHealthIssues && (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0b5f6681f..ee4e97119 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -264,6 +264,8 @@ export interface GSOTriggerRequest { apply_mode?: "genie_config" | "uc_artifact" | "both" levers?: number[] deploy_target?: string + deploy_space_id?: string + catalog_map?: Record } export interface GSOTriggerResponse { diff --git a/packages/genie-space-optimizer/src/genie_space_optimizer/backend/job_launcher.py b/packages/genie-space-optimizer/src/genie_space_optimizer/backend/job_launcher.py index de08d8954..795378dfb 100644 --- a/packages/genie-space-optimizer/src/genie_space_optimizer/backend/job_launcher.py +++ b/packages/genie-space-optimizer/src/genie_space_optimizer/backend/job_launcher.py @@ -363,6 +363,8 @@ def submit_optimization( triggered_by: str = "", experiment_name: str = "", deploy_target: str = "", + deploy_space_id: str = "", + catalog_map: str = "", warehouse_id: str = "", target_benchmark_count: str = "", ) -> tuple[str, int]: @@ -393,6 +395,8 @@ def submit_optimization( "triggered_by": triggered_by, "experiment_name": experiment_name, "deploy_target": deploy_target, + "deploy_space_id": deploy_space_id, + "catalog_map": catalog_map, "warehouse_id": warehouse_id, "target_benchmark_count": target_benchmark_count, }, diff --git a/packages/genie-space-optimizer/src/genie_space_optimizer/integration/trigger.py b/packages/genie-space-optimizer/src/genie_space_optimizer/integration/trigger.py index bf881e067..0ec718cb3 100644 --- a/packages/genie-space-optimizer/src/genie_space_optimizer/integration/trigger.py +++ b/packages/genie-space-optimizer/src/genie_space_optimizer/integration/trigger.py @@ -45,6 +45,8 @@ def trigger_optimization( apply_mode: str = "genie_config", levers: list[int] | None = None, deploy_target: str | None = None, + deploy_space_id: str | None = None, + catalog_map: dict[str, str] | None = None, ) -> TriggerResult: """Trigger a GSO optimization run using SQL Warehouse for state management. @@ -227,6 +229,8 @@ def trigger_optimization( triggered_by=caller_email, experiment_name=experiment_name or "", deploy_target=deploy_target or "", + deploy_space_id=deploy_space_id or "", + catalog_map=json.dumps(catalog_map) if catalog_map else "", warehouse_id=config.warehouse_id or "", ) diff --git a/packages/genie-space-optimizer/src/genie_space_optimizer/jobs/run_cross_env_deploy.py b/packages/genie-space-optimizer/src/genie_space_optimizer/jobs/run_cross_env_deploy.py index 09bbc6a66..b1b6c45fe 100644 --- a/packages/genie-space-optimizer/src/genie_space_optimizer/jobs/run_cross_env_deploy.py +++ b/packages/genie-space-optimizer/src/genie_space_optimizer/jobs/run_cross_env_deploy.py @@ -56,11 +56,13 @@ dbutils.widgets.text("model_version", "", "Model Version") dbutils.widgets.text("target_workspace_url", "", "Target Workspace URL") dbutils.widgets.text("target_space_id", "", "Target Space ID") +dbutils.widgets.text("catalog_map", "", "Catalog Mapping (JSON)") model_name = dbutils.widgets.get("model_name").strip() model_version = dbutils.widgets.get("model_version").strip() target_workspace_url = dbutils.widgets.get("target_workspace_url").strip() target_space_id = dbutils.widgets.get("target_space_id").strip() +catalog_map_raw = dbutils.widgets.get("catalog_map").strip() _banner("Resolved Parameters") _log( @@ -139,6 +141,29 @@ _log("Config loaded", keys=list(space_config.keys())) +# Remap catalog references if catalog_map is provided +catalog_map: dict[str, str] = {} +if catalog_map_raw: + try: + catalog_map = json.loads(catalog_map_raw) + except json.JSONDecodeError: + _log("WARNING: Could not parse catalog_map JSON, skipping remapping", raw=catalog_map_raw) + +if catalog_map: + _banner("Remapping Catalog References") + remapped = 0 + for source_list_key in ("tables", "metric_views"): + for src in space_config.get("data_sources", {}).get(source_list_key, []): + ident = src.get("identifier", "") + parts = ident.replace("`", "").split(".") + if len(parts) >= 3 and parts[0] in catalog_map: + old_ident = ident + parts[0] = catalog_map[parts[0]] + src["identifier"] = ".".join(parts) + _log("Remapped", old=old_ident, new=src["identifier"]) + remapped += 1 + _log("Catalog remapping complete", remapped=remapped, mappings=catalog_map) + # COMMAND ---------- # MAGIC %md From 1e0c9d0f800f5d0e64e4cd3a830a2cf735fc6a40 Mon Sep 17 00:00:00 2001 From: louiscsq Date: Fri, 17 Apr 2026 10:32:56 +1000 Subject: [PATCH 02/13] Add deployment status display in run detail view - RunDetailView: add deployment status banner showing target workspace, status (PENDING_APPROVAL/DEPLOYED/FAILED), and link to target workspace Color-coded: green=deployed, blue=pending, red=failed - Backend: expose deployTarget at top-level run response (was only in step detail before) - Types: add deployTarget to GSOPipelineRun interface Co-authored-by: Isaac --- backend/routers/auto_optimize.py | 1 + .../auto-optimize/RunDetailView.tsx | 54 ++++++++++++++++++- frontend/src/types/index.ts | 1 + 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py index d9e76f0e4..2284b04ad 100644 --- a/backend/routers/auto_optimize.py +++ b/backend/routers/auto_optimize.py @@ -1166,6 +1166,7 @@ async def get_run(run_id: RunId): "levers": levers, "links": links, "convergenceReason": run.get("convergence_reason"), + "deployTarget": run.get("deploy_target") or None, "deploymentStatus": run.get("deploy_status"), "labelingSessionUrl": run.get("labeling_session_url") or None, "labelingSessionName": run.get("labeling_session_name") or None, diff --git a/frontend/src/components/auto-optimize/RunDetailView.tsx b/frontend/src/components/auto-optimize/RunDetailView.tsx index a4fdb67a7..eed8ca1c9 100644 --- a/frontend/src/components/auto-optimize/RunDetailView.tsx +++ b/frontend/src/components/auto-optimize/RunDetailView.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react" -import { ArrowLeft, Cog, UserCheck, ExternalLink } from "lucide-react" +import { ArrowLeft, Cog, UserCheck, ExternalLink, Upload, CheckCircle2, Clock, AlertTriangle } from "lucide-react" import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { ScoreSummary } from "@/components/auto-optimize/ScoreSummary" @@ -157,6 +157,58 @@ export function RunDetailView({ runId, onBack }: RunDetailViewProps) {
)} + {/* Deployment Status Banner */} + {run.deployTarget && ( +
+ {run.deploymentStatus === "DEPLOYED" ? ( + + ) : run.deploymentStatus === "FAILED" ? ( + + ) : run.deploymentStatus === "PENDING_APPROVAL" ? ( + + ) : ( + + )} +
+

+ {run.deploymentStatus === "DEPLOYED" + ? "Deployed to target workspace" + : run.deploymentStatus === "PENDING_APPROVAL" + ? "Deployment awaiting approval" + : run.deploymentStatus === "FAILED" + ? "Deployment failed" + : `Deployment: ${run.deploymentStatus ?? "configured"}`} +

+

+ Target: {run.deployTarget} +

+
+ {run.deploymentStatus === "DEPLOYED" && ( + + Open Workspace + + + )} +
+ )} + {/* Tabs */}
- {/* Deployment (collapsible) */} -
- - {deployExpanded && ( -
-

- After optimization, deploy the optimized config to a target workspace. Leave blank to skip deployment. -

- -
- - setDeployTarget(e.target.value)} - placeholder="https://my-prod-workspace.cloud.databricks.com" - className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" - /> -
- -
- - setDeploySpaceId(e.target.value)} - placeholder="01f1347d7f1516ceaea7e5853166498f" - className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" - /> -
- -
-
- - -
- {catalogMappings.map((m, i) => ( -
- { - const next = [...catalogMappings] - next[i] = { ...next[i], source: e.target.value } - setCatalogMappings(next) - }} - placeholder="dev_catalog" - className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" - /> - - { - const next = [...catalogMappings] - next[i] = { ...next[i], target: e.target.value } - setCatalogMappings(next) - }} - placeholder="prod_catalog" - className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" - /> - -
- ))} - {catalogMappings.length === 0 && ( -

No catalog mappings — table references will be used as-is

- )} -
-
- )} -
- {/* Health Issues */} {hasHealthIssues && (
diff --git a/frontend/src/components/auto-optimize/RunDetailView.tsx b/frontend/src/components/auto-optimize/RunDetailView.tsx index eed8ca1c9..39c357280 100644 --- a/frontend/src/components/auto-optimize/RunDetailView.tsx +++ b/frontend/src/components/auto-optimize/RunDetailView.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react" -import { ArrowLeft, Cog, UserCheck, ExternalLink, Upload, CheckCircle2, Clock, AlertTriangle } from "lucide-react" +import { ArrowLeft, ChevronDown, ChevronRight, Cog, UserCheck, ExternalLink, Plus, Rocket, Trash2, Upload, CheckCircle2, Clock, AlertTriangle } from "lucide-react" import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { ScoreSummary } from "@/components/auto-optimize/ScoreSummary" @@ -9,6 +9,7 @@ import { PipelineDetailsModal } from "@/components/auto-optimize/PipelineDetails import { getAutoOptimizeRun, getAutoOptimizeQuestionResults, + deployOptimizationRun, } from "@/lib/api" import type { GSOPipelineRun, GSOQuestionDetail } from "@/types" @@ -19,6 +20,21 @@ interface RunDetailViewProps { type EvalTab = "baseline" | "final" +const DEPLOY_STORAGE_KEY = "genie-workbench:deploy-config" +const TERMINAL_STATUSES = new Set(["CONVERGED", "STALLED", "MAX_ITERATIONS", "APPLIED"]) + +function loadDeployConfig(): { targetUrl: string; spaceId: string; catalogMappings: { source: string; target: string }[] } { + try { + const raw = localStorage.getItem(DEPLOY_STORAGE_KEY) + if (raw) return JSON.parse(raw) + } catch {} + return { targetUrl: "", spaceId: "", catalogMappings: [] } +} + +function saveDeployConfig(config: { targetUrl: string; spaceId: string; catalogMappings: { source: string; target: string }[] }) { + localStorage.setItem(DEPLOY_STORAGE_KEY, JSON.stringify(config)) +} + const STATUS_VARIANT: Record = { CONVERGED: "success", APPLIED: "success", @@ -40,6 +56,15 @@ export function RunDetailView({ runId, onBack }: RunDetailViewProps) { const [selectedQuestionId, setSelectedQuestionId] = useState(null) const [showPipeline, setShowPipeline] = useState(false) + // Deploy dialog state + const [deployOpen, setDeployOpen] = useState(false) + const [deployTargetUrl, setDeployTargetUrl] = useState("") + const [deploySpaceId, setDeploySpaceId] = useState("") + const [deployCatalogMappings, setDeployCatalogMappings] = useState<{ source: string; target: string }[]>([]) + const [deploying, setDeploying] = useState(false) + const [deployError, setDeployError] = useState(null) + const [deploySuccess, setDeploySuccess] = useState(false) + useEffect(() => { getAutoOptimizeRun(runId) .then((r) => { @@ -157,8 +182,8 @@ export function RunDetailView({ runId, onBack }: RunDetailViewProps) {
)} - {/* Deployment Status Banner */} - {run.deployTarget && ( + {/* Deployment Status Banner (when already deployed) */} + {run.deployTarget && run.deploymentStatus && (
) : run.deploymentStatus === "FAILED" ? ( - ) : run.deploymentStatus === "PENDING_APPROVAL" ? ( - ) : ( - + )}

- {run.deploymentStatus === "DEPLOYED" - ? "Deployed to target workspace" - : run.deploymentStatus === "PENDING_APPROVAL" - ? "Deployment awaiting approval" - : run.deploymentStatus === "FAILED" - ? "Deployment failed" - : `Deployment: ${run.deploymentStatus ?? "configured"}`} + {run.deploymentStatus === "DEPLOYED" ? "Deployed to target workspace" + : run.deploymentStatus === "PENDING_APPROVAL" ? "Deployment awaiting approval" + : run.deploymentStatus === "FAILED" ? "Deployment failed" + : `Deployment: ${run.deploymentStatus}`}

-

- Target: {run.deployTarget} -

+

Target: {run.deployTarget}

{run.deploymentStatus === "DEPLOYED" && ( - - Open Workspace - + + Open Workspace )}
)} + {/* Deploy to Workspace — on-demand for completed runs */} + {TERMINAL_STATUSES.has(run.status) && !run.deploymentStatus && ( +
+ + {deployOpen && ( +
+

+ Deploy this optimized config to another workspace. Previous settings are remembered. +

+
+ + setDeployTargetUrl(e.target.value)} + placeholder="https://my-prod-workspace.cloud.databricks.com" + className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" /> +
+
+ + setDeploySpaceId(e.target.value)} + placeholder="01f1347d7f1516ceaea7e5853166498f" + className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" /> +
+
+
+ + +
+ {deployCatalogMappings.map((m, i) => ( +
+ { const next = [...deployCatalogMappings]; next[i] = { ...next[i], source: e.target.value }; setDeployCatalogMappings(next) }} + placeholder="dev_catalog" + className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" /> + + { const next = [...deployCatalogMappings]; next[i] = { ...next[i], target: e.target.value }; setDeployCatalogMappings(next) }} + placeholder="prod_catalog" + className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" /> + +
+ ))} + {deployCatalogMappings.length === 0 && ( +

No catalog mappings — table references will be used as-is

+ )} +
+ {deployError && ( +
{deployError}
+ )} + {deploySuccess && ( +
+ Deployment job triggered successfully. +
+ )} + +
+ )} +
+ )} + {/* Tabs */}
)} - {/* Deploy to Workspace — on-demand for completed runs */} - {TERMINAL_STATUSES.has(run.status) && !run.deploymentStatus && ( -
- - {deployOpen && ( -
-

- Deploy this optimized config to another workspace. Previous settings are remembered. -

-
- - setDeployTargetUrl(e.target.value)} - placeholder="https://my-prod-workspace.cloud.databricks.com" - className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" /> -
-
- - setDeploySpaceId(e.target.value)} - placeholder="01f1347d7f1516ceaea7e5853166498f" - className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" /> -
-
-
- - -
- {deployCatalogMappings.map((m, i) => ( -
- { const next = [...deployCatalogMappings]; next[i] = { ...next[i], source: e.target.value }; setDeployCatalogMappings(next) }} - placeholder="dev_catalog" - className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" /> - - { const next = [...deployCatalogMappings]; next[i] = { ...next[i], target: e.target.value }; setDeployCatalogMappings(next) }} - placeholder="prod_catalog" - className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" /> - -
- ))} - {deployCatalogMappings.length === 0 && ( -

No catalog mappings — table references will be used as-is

- )} -
- {deployError && ( -
{deployError}
- )} - {deploySuccess && ( -
- Deployment job triggered successfully. -
- )} - -
- )} -
- )} {/* Tabs */}
diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx new file mode 100644 index 000000000..fa1730d33 --- /dev/null +++ b/frontend/src/pages/DeployTab.tsx @@ -0,0 +1,294 @@ +/** + * DeployTab — Cross-workspace deployment for optimized Genie Spaces. + * Shows deployment config (remembered via localStorage), completed runs to deploy, and deployment status. + */ +import { useState, useEffect } from "react" +import { CheckCircle2, ExternalLink, Plus, Rocket, Trash2, Upload } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { getAutoOptimizeRunsForSpace, deployOptimizationRun } from "@/lib/api" +import type { GSORunSummary } from "@/types" + +const DEPLOY_STORAGE_KEY = "genie-workbench:deploy-config" +const TERMINAL_STATUSES = new Set(["CONVERGED", "STALLED", "MAX_ITERATIONS", "APPLIED"]) + +interface DeployConfig { + targetUrl: string + spaceId: string + catalogMappings: { source: string; target: string }[] +} + +function loadDeployConfig(): DeployConfig { + try { + const raw = localStorage.getItem(DEPLOY_STORAGE_KEY) + if (raw) return JSON.parse(raw) + } catch {} + return { targetUrl: "", spaceId: "", catalogMappings: [] } +} + +function saveDeployConfig(config: DeployConfig) { + localStorage.setItem(DEPLOY_STORAGE_KEY, JSON.stringify(config)) +} + +interface DeployTabProps { + spaceId: string +} + +export function DeployTab({ spaceId }: DeployTabProps) { + // Config state (loaded from localStorage) + const [targetUrl, setTargetUrl] = useState("") + const [targetSpaceId, setTargetSpaceId] = useState("") + const [catalogMappings, setCatalogMappings] = useState<{ source: string; target: string }[]>([]) + + // Runs + const [runs, setRuns] = useState([]) + const [runsLoading, setRunsLoading] = useState(true) + const [selectedRunId, setSelectedRunId] = useState(null) + + // Deploy state + const [deploying, setDeploying] = useState(false) + const [deployError, setDeployError] = useState(null) + const [deploySuccess, setDeploySuccess] = useState(false) + + // Load config from localStorage on mount + useEffect(() => { + const saved = loadDeployConfig() + setTargetUrl(saved.targetUrl) + setTargetSpaceId(saved.spaceId) + setCatalogMappings(saved.catalogMappings) + }, []) + + // Fetch completed runs + useEffect(() => { + setRunsLoading(true) + getAutoOptimizeRunsForSpace(spaceId) + .then((allRuns) => { + const completed = allRuns.filter((r) => TERMINAL_STATUSES.has(r.status)) + setRuns(completed) + if (completed.length > 0 && !selectedRunId) { + setSelectedRunId(completed[0].run_id) + } + }) + .catch(() => setRuns([])) + .finally(() => setRunsLoading(false)) + }, [spaceId]) + + async function handleDeploy() { + if (!selectedRunId || !targetUrl.trim()) return + setDeploying(true) + setDeployError(null) + setDeploySuccess(false) + + const catalogMap = catalogMappings.reduce>((acc, m) => { + if (m.source.trim() && m.target.trim()) acc[m.source.trim()] = m.target.trim() + return acc + }, {}) + + try { + await deployOptimizationRun(selectedRunId, { + target_workspace_url: targetUrl.trim(), + target_space_id: targetSpaceId.trim() || undefined, + catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined, + }) + saveDeployConfig({ targetUrl: targetUrl.trim(), spaceId: targetSpaceId.trim(), catalogMappings }) + setDeploySuccess(true) + } catch (e) { + setDeployError(e instanceof Error ? e.message : "Deployment failed") + } finally { + setDeploying(false) + } + } + + return ( +
+ {/* Deployment Config */} + + + + + Deploy to Workspace + + + +

+ Deploy an optimized Genie Space config to a target workspace. Settings are remembered for next time. +

+ +
+
+ + setTargetUrl(e.target.value)} + placeholder="https://my-prod-workspace.cloud.databricks.com" + className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+
+ + setTargetSpaceId(e.target.value)} + placeholder="01f1347d7f1516ceaea7e5853166498f" + className="w-full px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+
+ +
+
+ + +
+ {catalogMappings.map((m, i) => ( +
+ { + const next = [...catalogMappings] + next[i] = { ...next[i], source: e.target.value } + setCatalogMappings(next) + }} + placeholder="dev_catalog" + className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> + + { + const next = [...catalogMappings] + next[i] = { ...next[i], target: e.target.value } + setCatalogMappings(next) + }} + placeholder="prod_catalog" + className="flex-1 px-3 py-1.5 text-sm border border-default rounded-lg bg-elevated text-primary placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> + +
+ ))} + {catalogMappings.length === 0 && ( +

No catalog mappings — table references will be used as-is

+ )} +
+
+
+ + {/* Select a completed run */} + + + Select a completed run to deploy + + + {runsLoading ? ( +

Loading optimization runs...

+ ) : runs.length === 0 ? ( +

+ No completed optimization runs yet. Run an optimization first on the Optimize tab. +

+ ) : ( +
+ {runs.map((run) => ( + + ))} +
+ )} +
+
+ + {/* Deploy button + status */} + {deployError && ( +
+ {deployError} +
+ )} + + {deploySuccess && ( +
+ +
+

+ Deployment job triggered +

+

+ Target: {targetUrl} +

+
+ {targetUrl && ( + + Open Workspace + + + )} +
+ )} + + +
+ ) +} diff --git a/frontend/src/pages/SpaceDetail.tsx b/frontend/src/pages/SpaceDetail.tsx index efab5f35d..db9dec6f0 100644 --- a/frontend/src/pages/SpaceDetail.tsx +++ b/frontend/src/pages/SpaceDetail.tsx @@ -4,7 +4,7 @@ * Score tab includes an inline FixAgentPanel that slides in from the right. */ import { useState, useEffect, useRef } from "react" -import { ArrowLeft, Star, BarChart2, Clock, ExternalLink, Rocket, Play, Zap, ChevronDown, ChevronRight, Settings, RefreshCw } from "lucide-react" +import { ArrowLeft, Star, BarChart2, Clock, ExternalLink, Rocket, Play, Upload, Zap, ChevronDown, ChevronRight, Settings, RefreshCw } from "lucide-react" import { scanSpace, toggleStar, getSpaceHistory, getSpaceDetail, getActiveRunForSpace } from "@/lib/api" import { MATURITY_COLORS, getOptimizationLabel } from "@/lib/utils" import type { ScanResult, ScoreHistoryPoint, OptimizationEvent } from "@/types" @@ -13,10 +13,11 @@ import { HistoryTab } from "./HistoryTab" import { useAnalysis } from "@/hooks/useAnalysis" import { SpaceOverview } from "@/components/SpaceOverview" import { AutoOptimizeTab } from "@/components/auto-optimize/AutoOptimizeTab" +import { DeployTab } from "./DeployTab" import { FixAgentPanel } from "@/components/FixAgentPanel" -type Tab = "score" | "optimize" | "history" -const VALID_TABS: readonly string[] = ["score", "optimize", "history"] +type Tab = "score" | "optimize" | "history" | "deploy" +const VALID_TABS: readonly string[] = ["score", "optimize", "history", "deploy"] interface SpaceDetailProps { spaceId: string @@ -162,6 +163,7 @@ export function SpaceDetail({ spaceId, displayName, spaceUrl, initialTab, autoSc { id: "score", label: "Score", icon: }, { id: "optimize", label: "Optimize", icon: }, { id: "history", label: "History", icon: }, + { id: "deploy", label: "Deploy", icon: }, ] // Determine contextual action(s) based on scan results @@ -322,6 +324,9 @@ export function SpaceDetail({ spaceId, displayName, spaceUrl, initialTab, autoSc {activeTab === "history" && ( )} + {activeTab === "deploy" && ( + + )}
{/* Fix agent modal overlay */} From 6bb005eab41006947c38a803f76f12cd193aa80b Mon Sep 17 00:00:00 2001 From: louiscsq Date: Sat, 18 Apr 2026 15:43:43 +1000 Subject: [PATCH 06/13] Remove unused Upload import from RunDetailView Co-authored-by: Isaac --- frontend/src/components/auto-optimize/RunDetailView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/auto-optimize/RunDetailView.tsx b/frontend/src/components/auto-optimize/RunDetailView.tsx index c34801732..b8585933e 100644 --- a/frontend/src/components/auto-optimize/RunDetailView.tsx +++ b/frontend/src/components/auto-optimize/RunDetailView.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react" -import { ArrowLeft, Cog, UserCheck, ExternalLink, Upload, CheckCircle2, Clock, AlertTriangle } from "lucide-react" +import { ArrowLeft, Cog, UserCheck, ExternalLink, CheckCircle2, Clock, AlertTriangle } from "lucide-react" import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { ScoreSummary } from "@/components/auto-optimize/ScoreSummary" From 5f7a41d18d024b29acf21d779120c81cb7503e4f Mon Sep 17 00:00:00 2001 From: louiscsq Date: Sat, 18 Apr 2026 15:56:14 +1000 Subject: [PATCH 07/13] =?UTF-8?q?Fix=20error=20display=20in=20DeployTab=20?= =?UTF-8?q?=E2=80=94=20handle=20non-Error=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Isaac --- frontend/src/pages/DeployTab.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx index fa1730d33..d01351d73 100644 --- a/frontend/src/pages/DeployTab.tsx +++ b/frontend/src/pages/DeployTab.tsx @@ -93,7 +93,8 @@ export function DeployTab({ spaceId }: DeployTabProps) { saveDeployConfig({ targetUrl: targetUrl.trim(), spaceId: targetSpaceId.trim(), catalogMappings }) setDeploySuccess(true) } catch (e) { - setDeployError(e instanceof Error ? e.message : "Deployment failed") + const msg = e instanceof Error ? e.message : typeof e === "object" ? JSON.stringify(e) : String(e) + setDeployError(msg || "Deployment failed") } finally { setDeploying(false) } From 8de0d23c63c9ebe9ab1661a795e1a1e9e419fbdf Mon Sep 17 00:00:00 2001 From: louiscsq Date: Sat, 18 Apr 2026 16:01:56 +1000 Subject: [PATCH 08/13] Simplify deploy: fetch space config directly, no UC model needed Backend: - Replace POST /runs/{run_id}/deploy with POST /spaces/{space_id}/deploy - Fetches current space config via Genie API (get_serialized_space) - Applies catalog remapping in-memory - PATCHes directly to target workspace via patch_space_config - No UC model, no optimization run, no deployment job required Frontend: - Simplify DeployTab: remove run selection (not needed) - Just enter target config and click Deploy - deploySpace() API replaces deployOptimizationRun() Co-authored-by: Isaac --- backend/routers/auto_optimize.py | 75 +++++++++--------- frontend/src/lib/api.ts | 10 +-- frontend/src/pages/DeployTab.tsx | 132 ++++++------------------------- 3 files changed, 68 insertions(+), 149 deletions(-) diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py index d22b1c425..dfff16bd4 100644 --- a/backend/routers/auto_optimize.py +++ b/backend/routers/auto_optimize.py @@ -889,49 +889,48 @@ class DeployRequest(BaseModel): catalog_map: dict[str, str] | None = None -@router.post("/runs/{run_id}/deploy") -async def deploy_run(run_id: RunId, body: DeployRequest): - """Trigger cross-workspace deployment for a completed optimization run.""" - if not _is_configured(): - raise HTTPException(status_code=503, detail="Auto-Optimize is not configured.") +@router.post("/spaces/{space_id}/deploy") +async def deploy_space(space_id: str, body: DeployRequest): + """Deploy a Genie Space config to a target workspace. - # Load run to get UC model info - run = await gso_lakebase.load_gso_run(run_id) - if not run and _is_configured(): - config = _build_gso_config() - run = _fetch_run_via_sql(run_id, config) - if not run: - raise HTTPException(status_code=404, detail=f"Run {run_id} not found") + Fetches the current space config, applies catalog remapping, and + PATCHes it to the target workspace. No UC model or optimization run + required — works with any Genie Space. + """ + import json as _json - status = run.get("status", "") - terminal = {"CONVERGED", "STALLED", "MAX_ITERATIONS", "APPLIED"} - if status not in terminal: - raise HTTPException(status_code=400, detail=f"Run is {status} — can only deploy terminal runs") + # 1. Fetch source space config + try: + from backend.services.genie_client import get_serialized_space + space_config = get_serialized_space(genie_space_id=space_id) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to fetch space config: {e}") + + # 2. Apply catalog remapping + if body.catalog_map: + remapped = 0 + for key in ("tables", "metric_views"): + for src in space_config.get("data_sources", {}).get(key, []): + ident = src.get("identifier", "") + parts = ident.replace("`", "").split(".") + if len(parts) >= 3 and parts[0] in body.catalog_map: + parts[0] = body.catalog_map[parts[0]] + src["identifier"] = ".".join(parts) + remapped += 1 + logger.info("Remapped %d catalog references for deploy", remapped) + + # 3. PATCH to target workspace + try: + from databricks.sdk import WorkspaceClient + target_ws = WorkspaceClient(host=body.target_workspace_url.rstrip("/")) + target_space = body.target_space_id or space_id - # Get model name/version from the run's finalize output - model_name = run.get("uc_model_name") or run.get("model_name") - model_version = run.get("uc_model_version") or run.get("model_version") - if not model_name or not model_version: - raise HTTPException(status_code=400, detail="Run has no registered UC model — cannot deploy") + from genie_space_optimizer.common.genie_client import patch_space_config + result = patch_space_config(target_ws, target_space, space_config) - try: - import json as _json - from genie_space_optimizer.backend.job_launcher import ensure_deployment_job - sp_ws = get_service_principal_client() - config = _build_gso_config() - - job_run_id = ensure_deployment_job( - ws=sp_ws, - job_id=config.job_id, - model_name=model_name, - model_version=str(model_version), - target_workspace_url=body.target_workspace_url, - target_space_id=body.target_space_id or "", - catalog_map=_json.dumps(body.catalog_map) if body.catalog_map else "", - ) - return {"jobRunId": str(job_run_id), "status": "DEPLOYING"} + return {"status": "DEPLOYED", "targetSpaceId": target_space, "targetUrl": body.target_workspace_url} except Exception as e: - logger.exception("Failed to trigger deployment: %s", e) + logger.exception("Failed to deploy to target workspace: %s", e) raise HTTPException(status_code=500, detail=f"Deployment failed: {e}") diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1ee31df17..41bdb5794 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -407,12 +407,12 @@ export async function triggerAutoOptimize(request: GSOTriggerRequest): Promise } -): Promise<{ jobRunId: string; status: string }> { - return fetchWithTimeout<{ jobRunId: string; status: string }>( - `${API_BASE}/auto-optimize/runs/${runId}/deploy`, +): Promise<{ status: string; targetSpaceId: string; targetUrl: string }> { + return fetchWithTimeout<{ status: string; targetSpaceId: string; targetUrl: string }>( + `${API_BASE}/auto-optimize/spaces/${spaceId}/deploy`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx index d01351d73..576fa9317 100644 --- a/frontend/src/pages/DeployTab.tsx +++ b/frontend/src/pages/DeployTab.tsx @@ -1,16 +1,14 @@ /** - * DeployTab — Cross-workspace deployment for optimized Genie Spaces. - * Shows deployment config (remembered via localStorage), completed runs to deploy, and deployment status. + * DeployTab — Cross-workspace deployment for Genie Spaces. + * Deploys the current space config to a target workspace with optional catalog remapping. + * Settings are remembered via localStorage. */ import { useState, useEffect } from "react" import { CheckCircle2, ExternalLink, Plus, Rocket, Trash2, Upload } from "lucide-react" -import { Badge } from "@/components/ui/badge" import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card" -import { getAutoOptimizeRunsForSpace, deployOptimizationRun } from "@/lib/api" -import type { GSORunSummary } from "@/types" +import { deploySpace } from "@/lib/api" const DEPLOY_STORAGE_KEY = "genie-workbench:deploy-config" -const TERMINAL_STATUSES = new Set(["CONVERGED", "STALLED", "MAX_ITERATIONS", "APPLIED"]) interface DeployConfig { targetUrl: string @@ -35,20 +33,13 @@ interface DeployTabProps { } export function DeployTab({ spaceId }: DeployTabProps) { - // Config state (loaded from localStorage) const [targetUrl, setTargetUrl] = useState("") const [targetSpaceId, setTargetSpaceId] = useState("") const [catalogMappings, setCatalogMappings] = useState<{ source: string; target: string }[]>([]) - // Runs - const [runs, setRuns] = useState([]) - const [runsLoading, setRunsLoading] = useState(true) - const [selectedRunId, setSelectedRunId] = useState(null) - - // Deploy state const [deploying, setDeploying] = useState(false) const [deployError, setDeployError] = useState(null) - const [deploySuccess, setDeploySuccess] = useState(false) + const [deploySuccess, setDeploySuccess] = useState<{ targetUrl: string; targetSpaceId: string } | null>(null) // Load config from localStorage on mount useEffect(() => { @@ -58,26 +49,11 @@ export function DeployTab({ spaceId }: DeployTabProps) { setCatalogMappings(saved.catalogMappings) }, []) - // Fetch completed runs - useEffect(() => { - setRunsLoading(true) - getAutoOptimizeRunsForSpace(spaceId) - .then((allRuns) => { - const completed = allRuns.filter((r) => TERMINAL_STATUSES.has(r.status)) - setRuns(completed) - if (completed.length > 0 && !selectedRunId) { - setSelectedRunId(completed[0].run_id) - } - }) - .catch(() => setRuns([])) - .finally(() => setRunsLoading(false)) - }, [spaceId]) - async function handleDeploy() { - if (!selectedRunId || !targetUrl.trim()) return + if (!targetUrl.trim()) return setDeploying(true) setDeployError(null) - setDeploySuccess(false) + setDeploySuccess(null) const catalogMap = catalogMappings.reduce>((acc, m) => { if (m.source.trim() && m.target.trim()) acc[m.source.trim()] = m.target.trim() @@ -85,13 +61,13 @@ export function DeployTab({ spaceId }: DeployTabProps) { }, {}) try { - await deployOptimizationRun(selectedRunId, { + const result = await deploySpace(spaceId, { target_workspace_url: targetUrl.trim(), target_space_id: targetSpaceId.trim() || undefined, catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined, }) saveDeployConfig({ targetUrl: targetUrl.trim(), spaceId: targetSpaceId.trim(), catalogMappings }) - setDeploySuccess(true) + setDeploySuccess({ targetUrl: result.targetUrl, targetSpaceId: result.targetSpaceId }) } catch (e) { const msg = e instanceof Error ? e.message : typeof e === "object" ? JSON.stringify(e) : String(e) setDeployError(msg || "Deployment failed") @@ -112,7 +88,7 @@ export function DeployTab({ spaceId }: DeployTabProps) {

- Deploy an optimized Genie Space config to a target workspace. Settings are remembered for next time. + Deploy this Genie Space's current config to a target workspace. Catalog references are remapped automatically. Settings are remembered for next time.

@@ -194,101 +170,45 @@ export function DeployTab({ spaceId }: DeployTabProps) { - {/* Select a completed run */} - - - Select a completed run to deploy - - - {runsLoading ? ( -

Loading optimization runs...

- ) : runs.length === 0 ? ( -

- No completed optimization runs yet. Run an optimization first on the Optimize tab. -

- ) : ( -
- {runs.map((run) => ( - - ))} -
- )} -
-
- - {/* Deploy button + status */} + {/* Error */} {deployError && (
{deployError}
)} + {/* Success */} {deploySuccess && (

- Deployment job triggered + Deployed successfully

- Target: {targetUrl} + Space config deployed to {deploySuccess.targetUrl} (space: {deploySuccess.targetSpaceId})

- {targetUrl && ( - - Open Workspace - - - )} + + Open Workspace + +
)} + {/* Deploy button */}
) From f726bb019ffa5d1d13ebd17fab997cb7dc58efaf Mon Sep 17 00:00:00 2001 From: louiscsq Date: Sat, 18 Apr 2026 16:17:49 +1000 Subject: [PATCH 09/13] Fix deploy auth: use OBO token for cross-workspace PATCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app SP isn't registered in the target workspace. Use the user's OBO token instead — the user is authenticated in both workspaces. Co-authored-by: Isaac --- backend/routers/auto_optimize.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py index dfff16bd4..b808c3922 100644 --- a/backend/routers/auto_optimize.py +++ b/backend/routers/auto_optimize.py @@ -919,10 +919,21 @@ async def deploy_space(space_id: str, body: DeployRequest): remapped += 1 logger.info("Remapped %d catalog references for deploy", remapped) - # 3. PATCH to target workspace + # 3. PATCH to target workspace (use OBO token so user's identity authenticates) try: from databricks.sdk import WorkspaceClient - target_ws = WorkspaceClient(host=body.target_workspace_url.rstrip("/")) + from databricks.sdk.config import Config + from backend.services.auth import get_workspace_client + obo_ws = get_workspace_client() + obo_token = obo_ws.config.token + target_cfg = Config( + host=body.target_workspace_url.rstrip("/"), + token=obo_token, + auth_type="pat", + client_id=None, + client_secret=None, + ) + target_ws = WorkspaceClient(config=target_cfg) target_space = body.target_space_id or space_id from genie_space_optimizer.common.genie_client import patch_space_config From 5838c8ace61959c8e73d991e1f968fb6aa598efd Mon Sep 17 00:00:00 2001 From: louiscsq Date: Sat, 18 Apr 2026 16:20:42 +1000 Subject: [PATCH 10/13] Revert to SP auth for cross-workspace deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the app's SP credentials (client_id + client_secret) to authenticate to the target workspace. The SP must be registered in the target workspace — this is a one-time admin setup. Co-authored-by: Isaac --- backend/routers/auto_optimize.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py index b808c3922..3e0f38f05 100644 --- a/backend/routers/auto_optimize.py +++ b/backend/routers/auto_optimize.py @@ -919,21 +919,17 @@ async def deploy_space(space_id: str, body: DeployRequest): remapped += 1 logger.info("Remapped %d catalog references for deploy", remapped) - # 3. PATCH to target workspace (use OBO token so user's identity authenticates) + # 3. PATCH to target workspace using the app's SP credentials. + # The SP must be registered in the target workspace with CAN_MANAGE + # on the target Genie Space. try: from databricks.sdk import WorkspaceClient - from databricks.sdk.config import Config - from backend.services.auth import get_workspace_client - obo_ws = get_workspace_client() - obo_token = obo_ws.config.token - target_cfg = Config( + sp_ws = get_service_principal_client() + target_ws = WorkspaceClient( host=body.target_workspace_url.rstrip("/"), - token=obo_token, - auth_type="pat", - client_id=None, - client_secret=None, + client_id=sp_ws.config.client_id, + client_secret=sp_ws.config.client_secret, ) - target_ws = WorkspaceClient(config=target_cfg) target_space = body.target_space_id or space_id from genie_space_optimizer.common.genie_client import patch_space_config From 28c5bdd0df5b2a723a8456de0516753e2f0269d9 Mon Sep 17 00:00:00 2001 From: louiscsq Date: Sat, 18 Apr 2026 16:30:30 +1000 Subject: [PATCH 11/13] Handle deploy to new space: create when target space ID is blank When target space ID is not provided, create a new Genie Space in the target workspace using POST /api/2.0/genie/spaces with: - Title from the source space - First available SQL warehouse in target workspace - Parent path /Shared/ - Remapped serialized config When target space ID is provided, PATCH the existing space as before. Also return spaceUrl in the response so the frontend can link directly to the newly created Genie Space. Co-authored-by: Isaac --- backend/routers/auto_optimize.py | 51 +++++++++++++++++++++++++++----- frontend/src/lib/api.ts | 4 +-- frontend/src/pages/DeployTab.tsx | 8 ++--- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/backend/routers/auto_optimize.py b/backend/routers/auto_optimize.py index 3e0f38f05..ff5255226 100644 --- a/backend/routers/auto_optimize.py +++ b/backend/routers/auto_optimize.py @@ -919,9 +919,8 @@ async def deploy_space(space_id: str, body: DeployRequest): remapped += 1 logger.info("Remapped %d catalog references for deploy", remapped) - # 3. PATCH to target workspace using the app's SP credentials. - # The SP must be registered in the target workspace with CAN_MANAGE - # on the target Genie Space. + # 3. Connect to target workspace using the app's SP credentials. + # The SP must be registered in the target workspace. try: from databricks.sdk import WorkspaceClient sp_ws = get_service_principal_client() @@ -930,12 +929,50 @@ async def deploy_space(space_id: str, body: DeployRequest): client_id=sp_ws.config.client_id, client_secret=sp_ws.config.client_secret, ) - target_space = body.target_space_id or space_id + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to connect to target workspace: {e}") - from genie_space_optimizer.common.genie_client import patch_space_config - result = patch_space_config(target_ws, target_space, space_config) + # 4. Deploy: PATCH existing space or CREATE new one + try: + if body.target_space_id: + # Update existing space + from genie_space_optimizer.common.genie_client import patch_space_config + patch_space_config(target_ws, body.target_space_id, space_config) + target_space = body.target_space_id + logger.info("PATCHed existing space %s in target workspace", target_space) + else: + # Create new space in target workspace + from backend.services.genie_client import get_genie_space + source_space = get_genie_space(genie_space_id=space_id) + display_name = source_space.get("title", "Deployed Genie Space") + + # Find a warehouse in the target workspace + warehouses = list(target_ws.warehouses.list()) + if not warehouses: + raise ValueError("No SQL warehouse found in target workspace") + target_warehouse_id = warehouses[0].id + + response = target_ws.api_client.do( + method="POST", + path="/api/2.0/genie/spaces", + body={ + "title": display_name, + "description": f"Deployed from source workspace (space {space_id})", + "parent_path": "/Shared/", + "warehouse_id": target_warehouse_id, + "serialized_space": _json.dumps(space_config), + }, + ) + target_space = response.get("space_id", "") + logger.info("Created new space %s in target workspace", target_space) - return {"status": "DEPLOYED", "targetSpaceId": target_space, "targetUrl": body.target_workspace_url} + target_host = body.target_workspace_url.rstrip("/") + return { + "status": "DEPLOYED", + "targetSpaceId": target_space, + "targetUrl": target_host, + "spaceUrl": f"{target_host}/genie/rooms/{target_space}", + } except Exception as e: logger.exception("Failed to deploy to target workspace: %s", e) raise HTTPException(status_code=500, detail=f"Deployment failed: {e}") diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 41bdb5794..70bb35caf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -410,8 +410,8 @@ export async function triggerAutoOptimize(request: GSOTriggerRequest): Promise } -): Promise<{ status: string; targetSpaceId: string; targetUrl: string }> { - return fetchWithTimeout<{ status: string; targetSpaceId: string; targetUrl: string }>( +): Promise<{ status: string; targetSpaceId: string; targetUrl: string; spaceUrl?: string }> { + return fetchWithTimeout<{ status: string; targetSpaceId: string; targetUrl: string; spaceUrl?: string }>( `${API_BASE}/auto-optimize/spaces/${spaceId}/deploy`, { method: "POST", diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx index 576fa9317..9d02db8c4 100644 --- a/frontend/src/pages/DeployTab.tsx +++ b/frontend/src/pages/DeployTab.tsx @@ -39,7 +39,7 @@ export function DeployTab({ spaceId }: DeployTabProps) { const [deploying, setDeploying] = useState(false) const [deployError, setDeployError] = useState(null) - const [deploySuccess, setDeploySuccess] = useState<{ targetUrl: string; targetSpaceId: string } | null>(null) + const [deploySuccess, setDeploySuccess] = useState<{ targetUrl: string; targetSpaceId: string; spaceUrl?: string } | null>(null) // Load config from localStorage on mount useEffect(() => { @@ -67,7 +67,7 @@ export function DeployTab({ spaceId }: DeployTabProps) { catalog_map: Object.keys(catalogMap).length > 0 ? catalogMap : undefined, }) saveDeployConfig({ targetUrl: targetUrl.trim(), spaceId: targetSpaceId.trim(), catalogMappings }) - setDeploySuccess({ targetUrl: result.targetUrl, targetSpaceId: result.targetSpaceId }) + setDeploySuccess({ targetUrl: result.targetUrl, targetSpaceId: result.targetSpaceId, spaceUrl: result.spaceUrl }) } catch (e) { const msg = e instanceof Error ? e.message : typeof e === "object" ? JSON.stringify(e) : String(e) setDeployError(msg || "Deployment failed") @@ -190,12 +190,12 @@ export function DeployTab({ spaceId }: DeployTabProps) {

- Open Workspace + {deploySuccess.spaceUrl ? "Open Genie Space" : "Open Workspace"} From e7be82eb06b32bb7872c6fe168a87fed5e429eff Mon Sep 17 00:00:00 2001 From: louiscsq Date: Sat, 18 Apr 2026 17:06:04 +1000 Subject: [PATCH 12/13] Add prerequisites info to Deploy tab Document permission requirements: SP registration, Genie Space permissions, target catalog existence, and table access grants. Co-authored-by: Isaac --- frontend/src/pages/DeployTab.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx index 9d02db8c4..2e4a8e7a6 100644 --- a/frontend/src/pages/DeployTab.tsx +++ b/frontend/src/pages/DeployTab.tsx @@ -91,6 +91,16 @@ export function DeployTab({ spaceId }: DeployTabProps) { Deploy this Genie Space's current config to a target workspace. Catalog references are remapped automatically. Settings are remembered for next time.

+
+

Prerequisites

+
    +
  • The app's service principal must be registered in the target workspace
  • +
  • The SP needs permission to create Genie Spaces (or CAN_MANAGE on the target space if updating)
  • +
  • Target catalog and tables must exist in the target workspace (e.g. prod_bank.retail.*)
  • +
  • The SP needs SELECT on the target catalog's tables
  • +
+
+
From 94bee3dff90d85a96be16afe6e08db7b8220bcbd Mon Sep 17 00:00:00 2001 From: louiscsq Date: Sat, 18 Apr 2026 17:07:31 +1000 Subject: [PATCH 13/13] Fix prerequisites wording: use correct UC terminology Catalogs are accessed via shared metastore or target metastore configuration, not 'exist in the target workspace'. List the specific UC grants needed (USE_CATALOG, USE_SCHEMA, SELECT). Co-authored-by: Isaac --- frontend/src/pages/DeployTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/DeployTab.tsx b/frontend/src/pages/DeployTab.tsx index 2e4a8e7a6..d3fc02ac3 100644 --- a/frontend/src/pages/DeployTab.tsx +++ b/frontend/src/pages/DeployTab.tsx @@ -96,8 +96,8 @@ export function DeployTab({ spaceId }: DeployTabProps) {
  • The app's service principal must be registered in the target workspace
  • The SP needs permission to create Genie Spaces (or CAN_MANAGE on the target space if updating)
  • -
  • Target catalog and tables must exist in the target workspace (e.g. prod_bank.retail.*)
  • -
  • The SP needs SELECT on the target catalog's tables
  • +
  • The referenced catalogs must be accessible from the target workspace (shared metastore, or configured in target metastore)
  • +
  • The SP needs USE_CATALOG, USE_SCHEMA, and SELECT on the referenced tables