diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx index 09cf5ff0219..8839b081b55 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx @@ -103,8 +103,11 @@ export function PromptGroup({ void navigate({ to: "/settings/projects/$projectId", params: { projectId: targetProjectId }, + search: { + hostId: draft.hostId ?? machineId ?? undefined, + }, }); - }, [closeModal, navigate, selectedProject?.id]); + }, [closeModal, draft.hostId, machineId, navigate, selectedProject?.id]); const { baseBranch, hostId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx index c93c0d2e7ad..e19226947d5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx @@ -17,10 +17,14 @@ export const Route = createFileRoute( )({ component: ProjectDetailPage, notFoundComponent: NotFound, + validateSearch: (search: Record): { hostId?: string } => ({ + hostId: typeof search.hostId === "string" ? search.hostId : undefined, + }), }); function ProjectDetailPage() { const { projectId } = Route.useParams(); + const { hostId } = Route.useSearch(); const collections = useCollections(); const { data: session } = authClient.useSession(); const searchQuery = useSettingsSearchQuery(); @@ -49,7 +53,7 @@ function ProjectDetailPage() { }, [searchQuery]); if (v2Match.length > 0) { - return ; + return ; } return ; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx index 2ceda6e05e8..eb7a546ea60 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx @@ -1,6 +1,8 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; @@ -15,11 +17,17 @@ import { V2ScriptsEditor } from "./components/V2ScriptsEditor"; interface V2ProjectSettingsProps { projectId: string; + hostId: string | null; } -export function V2ProjectSettings({ projectId }: V2ProjectSettingsProps) { +export function V2ProjectSettings({ + projectId, + hostId, +}: V2ProjectSettingsProps) { const collections = useCollections(); - const { activeHostUrl } = useLocalHostService(); + const { machineId } = useLocalHostService(); + const targetHostUrl = useHostUrl(hostId); + const targetHostId = hostId ?? machineId; const { data: v2Project } = useLiveQuery( (q) => @@ -30,12 +38,32 @@ export function V2ProjectSettings({ projectId }: V2ProjectSettingsProps) { [collections, projectId], ); + const { data: hostRows = [] } = useLiveQuery( + (q) => + q + .from({ hosts: collections.v2Hosts }) + .where(({ hosts }) => eq(hosts.machineId, targetHostId ?? "")) + .select(({ hosts }) => ({ + machineId: hosts.machineId, + name: hosts.name, + })), + [collections, targetHostId], + ); + const targetHostName = useMemo(() => { + if (hostRows[0]?.name) return hostRows[0].name; + if (!targetHostId || targetHostId === machineId) return "this device"; + return targetHostId; + }, [hostRows, machineId, targetHostId]); + const isRemoteTarget = Boolean( + targetHostId && machineId && targetHostId !== machineId, + ); + const { data: hostProject, refetch: refetchHostProject } = useQuery({ - queryKey: ["host-project", "get", activeHostUrl, projectId], - enabled: !!activeHostUrl, + queryKey: ["host-project", "get", targetHostUrl, projectId], + enabled: !!targetHostUrl, queryFn: async () => { - if (!activeHostUrl) return null; - const client = getHostServiceClientByUrl(activeHostUrl); + if (!targetHostUrl) return null; + const client = getHostServiceClientByUrl(targetHostUrl); return client.project.get.query({ projectId }); }, }); @@ -61,12 +89,15 @@ export function V2ProjectSettings({ projectId }: V2ProjectSettingsProps) { refetchHostProject()} /> @@ -82,12 +113,12 @@ export function V2ProjectSettings({ projectId }: V2ProjectSettingsProps) { /> - {activeHostUrl && ( + {targetHostUrl && ( - + )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx index be251ef1531..03e4e89e740 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx @@ -9,14 +9,13 @@ import { AlertDialogTitle, } from "@superset/ui/alert-dialog"; import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; import { toast } from "@superset/ui/sonner"; import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { showHostServiceUnavailableToast } from "renderer/lib/host-service-unavailable"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; -import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { ClickablePath } from "../../../../../../components/ClickablePath"; interface BackfillConflict { @@ -28,6 +27,9 @@ interface ProjectLocationSectionProps { projectId: string; currentPath: string | null; repoCloneUrl: string | null; + hostUrl: string | null; + hostName: string; + isRemoteTarget: boolean; onChanged?: () => void; } @@ -35,10 +37,11 @@ export function ProjectLocationSection({ projectId, currentPath, repoCloneUrl, + hostUrl, + hostName, + isRemoteTarget, onChanged, }: ProjectLocationSectionProps) { - const hostService = useLocalHostService(); - const { activeHostUrl } = hostService; const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); const navigate = useNavigate(); const { ensureProjectInSidebar, ensureWorkspaceInSidebar } = @@ -47,16 +50,16 @@ export function ProjectLocationSection({ const [pendingPath, setPendingPath] = useState(null); const [conflict, setConflict] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [remoteImportPath, setRemoteImportPath] = useState(""); + const [remoteCloneParentDir, setRemoteCloneParentDir] = useState(""); const runSetup = async (repoPath: string, allowRelocate: boolean) => { - if (!activeHostUrl) { - showHostServiceUnavailableToast(hostService, { - action: allowRelocate ? "relocate the project" : "set up the project", - }); + if (!hostUrl) { + toast.error(`Host unavailable: ${hostName}`); return false; } try { - const client = getHostServiceClientByUrl(activeHostUrl); + const client = getHostServiceClientByUrl(hostUrl); const result = await client.project.setup.mutate({ projectId, mode: { kind: "import", repoPath, allowRelocate }, @@ -80,14 +83,12 @@ export function ProjectLocationSection({ }; const runClone = async (parentDir: string) => { - if (!activeHostUrl) { - showHostServiceUnavailableToast(hostService, { - action: "clone the project", - }); + if (!hostUrl) { + toast.error(`Host unavailable: ${hostName}`); return false; } try { - const client = getHostServiceClientByUrl(activeHostUrl); + const client = getHostServiceClientByUrl(hostUrl); const result = await client.project.setup.mutate({ projectId, mode: { kind: "clone", parentDir }, @@ -107,10 +108,8 @@ export function ProjectLocationSection({ }; const pickPath = async (title: string) => { - if (!activeHostUrl) { - showHostServiceUnavailableToast(hostService, { - action: "choose a project path", - }); + if (!hostUrl) { + toast.error(`Host unavailable: ${hostName}`); return null; } try { @@ -127,18 +126,48 @@ export function ProjectLocationSection({ }; const handleImport = async () => { + if (isRemoteTarget) { + const path = remoteImportPath.trim(); + if (!path) { + toast.error(`Enter a path on ${hostName}`); + return; + } + if (!hostUrl) { + toast.error(`Host unavailable: ${hostName}`); + return; + } + setIsSubmitting(true); + let keepSubmitting = false; + try { + const client = getHostServiceClientByUrl(hostUrl); + const precheck = await client.project.findBackfillConflict.query({ + projectId, + repoPath: path, + }); + if (precheck.conflict) { + setConflict(precheck.conflict); + keepSubmitting = true; + return; + } + await runSetup(path, false); + setRemoteImportPath(""); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + if (!keepSubmitting) setIsSubmitting(false); + } + return; + } const path = await pickPath("Select project location"); if (!path) return; - if (!activeHostUrl) { - showHostServiceUnavailableToast(hostService, { - action: "check the project location", - }); + if (!hostUrl) { + toast.error(`Host unavailable: ${hostName}`); return; } setIsSubmitting(true); let keepSubmitting = false; try { - const client = getHostServiceClientByUrl(activeHostUrl); + const client = getHostServiceClientByUrl(hostUrl); const precheck = await client.project.findBackfillConflict.query({ projectId, repoPath: path, @@ -157,6 +186,20 @@ export function ProjectLocationSection({ }; const handleClone = async () => { + if (isRemoteTarget) { + const parentDir = remoteCloneParentDir.trim(); + if (!parentDir) { + toast.error(`Enter a parent directory on ${hostName}`); + return; + } + setIsSubmitting(true); + try { + await runClone(parentDir); + } finally { + setIsSubmitting(false); + } + return; + } const parentDir = await pickPath("Select parent directory to clone into"); if (!parentDir) return; setIsSubmitting(true); @@ -174,14 +217,12 @@ export function ProjectLocationSection({ toast.info("Project is already at that location"); return; } - if (!activeHostUrl) { - showHostServiceUnavailableToast(hostService, { - action: "check the project location", - }); + if (!hostUrl) { + toast.error(`Host unavailable: ${hostName}`); return; } try { - const client = getHostServiceClientByUrl(activeHostUrl); + const client = getHostServiceClientByUrl(hostUrl); const precheck = await client.project.findBackfillConflict.query({ projectId, repoPath: path, @@ -213,7 +254,7 @@ export function ProjectLocationSection({ ) : ( - Not set up on this device. + Not set up on {hostName}. )} @@ -229,31 +270,82 @@ export function ProjectLocationSection({ ) : (
- - + {isRemoteTarget ? ( +
+
+ + setRemoteImportPath(event.currentTarget.value) + } + placeholder={`Existing repo path on ${hostName}`} + className="h-8" + /> + +
+
+ + setRemoteCloneParentDir(event.currentTarget.value) + } + placeholder={`Parent directory on ${hostName}`} + className="h-8" + disabled={!repoCloneUrl} + /> + +
+
+ ) : ( + <> + + + + )}
)} @@ -273,7 +365,7 @@ export function ProjectLocationSection({ This repository is already linked to project " {conflict?.name ?? ""}" in this organization. Open that project to - set it up on this device. + set it up on {hostName}. diff --git a/packages/cli/src/commands/projects/list/command.ts b/packages/cli/src/commands/projects/list/command.ts index f8dd7b69bde..29c6055a35c 100644 --- a/packages/cli/src/commands/projects/list/command.ts +++ b/packages/cli/src/commands/projects/list/command.ts @@ -1,20 +1,47 @@ -import { CLIError, table } from "@superset/cli-framework"; +import { boolean, CLIError, string, table } from "@superset/cli-framework"; import { command } from "../../../lib/command"; +import { resolveHostFilter, resolveHostTarget } from "../../../lib/host-target"; export default command({ description: "List projects in the active organization", display: (data) => table( data as Record[], - ["name", "slug", "repoCloneUrl", "id"], - ["NAME", "SLUG", "REPO", "ID"], + ["name", "slug", "repoCloneUrl", "setUp", "path", "id"], + ["NAME", "SLUG", "REPO", "SET UP", "PATH", "ID"], ), - run: async ({ ctx }) => { + options: { + host: string().desc("Show setup status for a specific host machineId"), + local: boolean().desc("Show setup status for this machine"), + }, + run: async ({ ctx, options }) => { const organizationId = ctx.config.organizationId; if (!organizationId) { throw new CLIError("No active organization", "Run: superset auth login"); } - return ctx.api.v2Project.list.query({ organizationId }); + const projects = await ctx.api.v2Project.list.query({ organizationId }); + const hostId = resolveHostFilter({ + host: options.host ?? undefined, + local: options.local ?? undefined, + }); + const target = resolveHostTarget({ + requestedHostId: hostId, + organizationId, + userJwt: ctx.bearer, + }); + const hostProjects = await target.client.project.list.query(); + const hostProjectById = new Map( + hostProjects.map((project) => [project.id, project]), + ); + + return projects.map((project) => { + const hostProject = hostProjectById.get(project.id); + return { + ...project, + setUp: hostProject ? "yes" : "no", + path: hostProject?.repoPath ?? "-", + }; + }); }, }); diff --git a/packages/cli/src/commands/projects/setup/command.ts b/packages/cli/src/commands/projects/setup/command.ts index 416097b9bde..ec4b475f50f 100644 --- a/packages/cli/src/commands/projects/setup/command.ts +++ b/packages/cli/src/commands/projects/setup/command.ts @@ -1,14 +1,18 @@ import { boolean, CLIError, positional, string } from "@superset/cli-framework"; import { command } from "../../../lib/command"; -import { requireHostTarget, resolveHostTarget } from "../../../lib/host-target"; +import { resolveHostFilter, resolveHostTarget } from "../../../lib/host-target"; export default command({ description: "Adopt an existing project on a host (clone its repo or import a folder)", - args: [positional("id").required().desc("Project UUID to adopt")], + args: [positional("id").desc("Project UUID to adopt")], options: { host: string().desc("Target host machineId"), local: boolean().desc("Target this machine"), + project: string().desc("Project UUID to adopt"), + path: string().desc( + "Existing local repo path on the target host (alias for --import)", + ), parentDir: string().desc( "Parent directory to clone the project's repo into (clone mode)", ), @@ -20,26 +24,45 @@ export default command({ ), }, run: async ({ ctx, args, options }) => { - const projectId = args.id as string; + const projectId = (options.project ?? args.id) as string | undefined; + if (!projectId) { + throw new CLIError( + "Project ID required", + "Pass --project , or provide the project ID as the first argument.", + ); + } + if (options.project && args.id && options.project !== args.id) { + throw new CLIError( + "Project ID specified twice", + "Use either --project or the positional project ID, not both.", + ); + } const organizationId = ctx.config.organizationId; if (!organizationId) { throw new CLIError("No active organization", "Run: superset auth login"); } - if (Boolean(options.parentDir) === Boolean(options.import)) { + if (options.path && options.import) { + throw new CLIError( + "Pass either --path or --import, not both", + "--path is an alias for --import.", + ); + } + const importPath = options.path ?? options.import; + if (Boolean(options.parentDir) === Boolean(importPath)) { throw new CLIError( - "Specify exactly one of --parent-dir or --import", - "Use --parent-dir to clone, or --import to register an existing folder", + "Specify exactly one of --parent-dir or --path", + "Use --parent-dir to clone, or --path to register an existing folder.", ); } - if (options.allowRelocate && !options.import) { + if (options.allowRelocate && !importPath) { throw new CLIError( - "--allow-relocate only applies to --import", - "Drop --allow-relocate, or switch to --import ", + "--allow-relocate only applies to --path", + "Drop --allow-relocate, or switch to --path .", ); } - const hostId = requireHostTarget({ + const hostId = resolveHostFilter({ host: options.host ?? undefined, local: options.local ?? undefined, }); @@ -57,7 +80,7 @@ export default command({ } : { kind: "import" as const, - repoPath: options.import as string, + repoPath: importPath as string, allowRelocate: options.allowRelocate ?? false, };