Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .superset/lib/setup/steps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,38 @@ step_start_electric() {
return 0
}

# Ports we must avoid because the OS (or commonly-installed services) listen on
# them, OR because Node/Next.js refuses to bind them. Bases whose
# [base, base+range) window contains any of these are skipped during allocation.
#
# - 5000, 7000: macOS Control Center / AirPlay Receiver (Sonoma+). Cannot be
# freed without disabling AirPlay Receiver in System Settings, so we just
# route around them.
# - Node/Next.js "unsafe ports" in our [3000, ...) allocation range. Next.js
# refuses to start on these with errors like "Bad port: '5060' is reserved
# for sip" (see https://nextjs.org/docs/messages/reserved-port).
# 3659 apple-sasl
# 4045 lockd / npp
# 5060 sip
# 5061 sips
# 6000 X11
# 6566 sane-port
# 6665-6669, 6697 IRC / IRC+TLS
SUPERSET_RESERVED_PORTS="3659 4045 5000 5060 5061 6000 6566 6665 6666 6667 6668 6669 6697 7000"

# Returns 0 if the [base, base+range) window contains no reserved port.
port_base_is_safe() {
local base=$1
local range=$2
local reserved
for reserved in $SUPERSET_RESERVED_PORTS; do
if [ "$reserved" -ge "$base" ] && [ "$reserved" -lt "$((base + range))" ]; then
Comment on lines +330 to +335
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply reserved-port guard when selecting port base

This patch introduces SUPERSET_RESERVED_PORTS and port_base_is_safe, but the allocator loop still advances candidates only by checking whether the base is already used, never whether the [base, base+range) window contains reserved ports. As a result, allocations can still land on ranges containing 5000/5060/etc., so the new reserved-port logic is effectively dead code and the startup failures it targets remain possible.

Useful? React with 👍 / 👎.

return 1
fi
done
return 0
}

allocate_port_base() {
local alloc_file="$HOME/.superset/port-allocations.json"
local lock_dir="$HOME/.superset/port-allocations.lock"
Expand Down
606 changes: 606 additions & 0 deletions apps/desktop/plans/done/20260504-1200-v2-onboarding-flow.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { createRingtoneRouter } from "./ringtone";
import { createScratchRouter } from "./scratch";
import { createServiceStatusRouter } from "./service-status";
import { createSettingsRouter } from "./settings";
import { createSystemRouter } from "./system";
import { createTabTearoffRouter } from "./tab-tearoff";
import { createTerminalRouter } from "./terminal";
import { createUiStateRouter } from "./ui-state";
Expand Down Expand Up @@ -81,6 +82,7 @@ export const createAppRouter = (
referenceGraph: createReferenceGraphRouter(),
external: createExternalRouter(),
settings: createSettingsRouter(),
system: createSystemRouter(),
config: createConfigRouter(),
databases: createDatabasesRouter(),
device: createDeviceRouter(),
Expand Down
5 changes: 2 additions & 3 deletions apps/desktop/src/lib/trpc/routers/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,13 @@ export const createPermissionsRouter = () => {

requestAppleEvents: publicProcedure.mutation(async () => {
await shell.openExternal(
"x-apple.systempreferences:com.apple.preference.security?Privacy_Automation",
"x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Automation",
);
}),

// No deep link exists for Local Network — open the general Privacy & Security pane
requestLocalNetwork: publicProcedure.mutation(async () => {
await shell.openExternal(
"x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension",
"x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_LocalNetwork",
);
}),
});
Expand Down
50 changes: 50 additions & 0 deletions apps/desktop/src/lib/trpc/routers/system.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { publicProcedure, router } from "..";

const execFileAsync = promisify(execFile);

const KNOWN_GH_PATHS = [
"/opt/homebrew/bin/gh",
"/usr/local/bin/gh",
"/usr/bin/gh",
"/bin/gh",
];

interface GhDetectResult {
installed: boolean;
version: string | null;
path: string | null;
}

async function tryGh(path: string): Promise<GhDetectResult | null> {
try {
const { stdout } = await execFileAsync(path, ["--version"], {
timeout: 3000,
});
const firstLine = stdout.split("\n")[0]?.trim() ?? "";
const match = firstLine.match(/gh version (\S+)/);
const version = match?.[1] ?? null;
return { installed: true, version, path };
} catch {
return null;
}
}

async function detectGhCli(): Promise<GhDetectResult> {
for (const path of KNOWN_GH_PATHS) {
const result = await tryGh(path);
if (result) return result;
}
const result = await tryGh("gh");
if (result) return result;
return { installed: false, version: null, path: null };
}

export const createSystemRouter = () => {
return router({
detectGhCli: publicProcedure.query(detectGhCli),
});
};

export type SystemRouter = ReturnType<typeof createSystemRouter>;
23 changes: 12 additions & 11 deletions apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import type {
AgentDefinitionId,
ResolvedAgentConfig,
} from "@superset/shared/agent-settings";
import {
Select,
SelectContent,
Expand All @@ -18,8 +14,16 @@ import {

const CONFIGURE_AGENTS_VALUE = "__configure_agents__";

// v1 callers' `id` doubles as the icon key. v2 ids are UUIDs, so v2 callers
// pass `iconId: presetId` to keep the preset-keyed icon lookup working.
export interface AgentSelectAgent {
id: string;
label: string;
iconId?: string;
}

interface AgentSelectProps<T extends string> {
agents: ResolvedAgentConfig[];
agents: AgentSelectAgent[];
value?: T;
placeholder: string;
onValueChange: (value: T) => void;
Expand Down Expand Up @@ -49,13 +53,10 @@ export function AgentSelect<T extends string>({
}: AgentSelectProps<T>) {
const navigate = useNavigate();
const isDark = useIsDarkTheme();
const selectableIds = new Set<AgentDefinitionId>(
agents.map((agent) => agent.id),
);
const selectableIds = new Set<string>(agents.map((agent) => agent.id));
const selectedValue =
value != null &&
((allowNone && value === noneValue) ||
selectableIds.has(value as AgentDefinitionId))
((allowNone && value === noneValue) || selectableIds.has(value))
? value
: undefined;
const showSeparator = (allowNone || agents.length > 0) && !disabled;
Expand Down Expand Up @@ -84,7 +85,7 @@ export function AgentSelect<T extends string>({
<SelectItem value={noneValue}>{noneLabel}</SelectItem>
)}
{agents.map((agent) => {
const icon = getPresetIcon(agent.id, isDark);
const icon = getPresetIcon(agent.iconId ?? agent.id, isDark);
return (
<SelectItem key={agent.id} value={agent.id}>
<span className="flex items-center gap-2">
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/components/AgentSelect/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { AgentSelect } from "./AgentSelect";
export { AgentSelect, type AgentSelectAgent } from "./AgentSelect";
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/hooks/useV2AgentChoices/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useV2AgentChoices } from "./useV2AgentChoices";
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useMemo } from "react";
import type { AgentSelectAgent } from "renderer/components/AgentSelect";
import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs";

interface UseV2AgentChoicesResult {
agents: AgentSelectAgent[];
isFetched: boolean;
}

const SUPERSET_AGENT: AgentSelectAgent = {
id: "superset",
label: "Superset",
iconId: "superset",
};

// Superset chat isn't in the host's `host_agent_configs` table — it's
// routed by id inside `runAgentInWorkspace`. Append after the host's
// terminal rows so the user's preferred terminal agents stay on top.
export function useV2AgentChoices(
hostUrl: string | null,
): UseV2AgentChoicesResult {
const query = useV2AgentConfigs(hostUrl);
const agents = useMemo<AgentSelectAgent[]>(() => {
const terminalAgents: AgentSelectAgent[] = (query.data ?? []).map(
(config) => ({
id: config.id,
label: config.label,
iconId: config.presetId,
}),
);
return [...terminalAgents, SUPERSET_AGENT];
}, [query.data]);

return { agents, isFetched: query.isFetched };
}
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/hooks/useV2AgentConfigs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
useV2AgentConfigs,
V2_AGENT_CONFIGS_QUERY_KEY,
} from "./useV2AgentConfigs";
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { HostAgentConfigDto } from "@superset/host-service/settings";
import { useQuery } from "@tanstack/react-query";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";

export const V2_AGENT_CONFIGS_QUERY_KEY = ["host-agent-configs"] as const;

/**
* Caller passes the host URL explicitly so this hook works for any host the
* user is targeting (local, remote-via-relay, or whatever the new-workspace
* modal has resolved). Cache is keyed on URL so distinct hosts don't share
* entries. Configs only change via Settings → Agents mutations that invalidate
* this key — `staleTime: Infinity` keeps the startup prefetch warm across
* navigation instead of every consumer refetching on mount.
*/
export function useV2AgentConfigs(hostUrl: string | null) {
return useQuery({
queryKey: [...V2_AGENT_CONFIGS_QUERY_KEY, hostUrl] as const,
enabled: !!hostUrl,
queryFn: () => {
if (!hostUrl) return [] as HostAgentConfigDto[];
return getHostServiceClientByUrl(
hostUrl,
).settings.agentConfigs.list.query();
},
staleTime: Number.POSITIVE_INFINITY,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export async function launchChatAdapter(
paneId = created.paneId;
}

tabs.setTabAutoTitle(tabId, "Superset Chat");
tabs.setTabAutoTitle(tabId, "Superset");

const pane = tabs.getPane(paneId);
let sessionId = request.chat.sessionId ?? pane?.chat?.sessionId ?? null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useMatchRoute } from "@tanstack/react-router";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader";
import { DashboardSidebarHoverCardOverlay } from "./components/DashboardSidebarHoverCardOverlay";
import { DashboardSidebarPortsList } from "./components/DashboardSidebarPortsList";
import { DashboardSidebarProjectSection } from "./components/DashboardSidebarProjectSection";
import { DashboardSidebarSectionRenameProvider } from "./components/DashboardSidebarSectionRenameContext";
import { V2SetupScriptCard } from "./components/V2SetupScriptCard";
import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData";
import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts";
import { DashboardSidebarHoverProvider } from "./providers/DashboardSidebarHoverProvider";
Expand Down Expand Up @@ -91,6 +94,10 @@ export function DashboardSidebar({
useDashboardSidebarData();
const workspaceShortcutLabels = useDashboardSidebarShortcuts(groups);
const { reorderProjects } = useDashboardSidebarState();
const matchRoute = useMatchRoute();
const { activeHostUrl } = useLocalHostService();
const v2RouteMatch = matchRoute({ to: "/v2-workspace/$workspaceId" });
const activeV2WorkspaceId = v2RouteMatch ? v2RouteMatch.workspaceId : null;

const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
Expand Down Expand Up @@ -120,6 +127,26 @@ export function DashboardSidebar({
.filter((g): g is DashboardSidebarProject => g != null);
}, [groups, projectOrder]);

const activeV2Project = useMemo(() => {
if (!activeV2WorkspaceId) return null;
for (const project of groups) {
for (const child of project.children) {
if (
child.type === "workspace" &&
child.workspace.id === activeV2WorkspaceId
) {
return project;
}
if (child.type === "section") {
for (const ws of child.section.workspaces) {
if (ws.id === activeV2WorkspaceId) return project;
}
}
}
}
return null;
}, [groups, activeV2WorkspaceId]);

const handleDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
if (over && active.id !== over.id) {
Expand Down Expand Up @@ -194,6 +221,13 @@ export function DashboardSidebar({
</DndContext>
</div>
{!isCollapsed && <DashboardSidebarPortsList />}
{!isCollapsed && activeV2Project && activeHostUrl && (
<V2SetupScriptCard
hostUrl={activeHostUrl}
projectId={activeV2Project.id}
projectName={activeV2Project.name}
/>
)}
</div>
</DashboardSidebarHoverCardOverlay>
</DashboardSidebarHoverProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { SidebarCard } from "@superset/ui/sidebar-card";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { useV2SetupCardDismissalsStore } from "renderer/stores/v2-setup-card-dismissals";

interface V2SetupScriptCardProps {
hostUrl: string;
projectId: string;
projectName: string;
isCollapsed?: boolean;
}

export function V2SetupScriptCard({
hostUrl,
projectId,
projectName,
isCollapsed,
}: V2SetupScriptCardProps) {
const navigate = useNavigate();
const isDismissed = useV2SetupCardDismissalsStore((s) =>
s.isDismissed(projectId),
);
const dismiss = useV2SetupCardDismissalsStore((s) => s.dismiss);

const { data: shouldShow } = useQuery({
queryKey: ["host-config", "shouldShowSetupCard", hostUrl, projectId],
queryFn: () =>
getHostServiceClientByUrl(hostUrl).config.shouldShowSetupCard.query({
projectId,
}),
refetchOnWindowFocus: true,
});

if (isCollapsed || isDismissed || !shouldShow) return null;

return (
<AnimatePresence>
<motion.div
key={projectId}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className="px-3 pb-2"
>
<SidebarCard
badge="Setup"
title="Setup scripts"
description={`Automate workspace setup for ${projectName}`}
actionLabel="Configure"
onAction={() =>
navigate({
to: "/settings/projects/$projectId",
params: { projectId },
})
}
onDismiss={() => dismiss(projectId)}
/>
</motion.div>
</AnimatePresence>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { V2SetupScriptCard } from "./V2SetupScriptCard";
Loading
Loading