Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ export const previewServersController = createRPCController({
listForWorkspace: async (args: { projectId: string; workspaceId: string }) =>
previewServerService.listForWorkspace(args),

listAll: async () => previewServerService.listAll(),

forwardManual: async (request: ManualPreviewServerRequest) =>
previewServerService.forwardManual(request),

stop: async (id: string) => previewServerService.stop(id),

stopForWorkspace: async (args: { projectId: string; workspaceId: string }) =>
previewServerService.stopForWorkspace(args.projectId, args.workspaceId),

stopAll: async () => previewServerService.stopAll(),

restart: async (id: string) => previewServerService.restart(id),
});
Original file line number Diff line number Diff line change
Expand Up @@ -590,4 +590,40 @@ describe('PreviewServerService', () => {
'preview:ssh:auto:project-1:workspace-1:connection-1:5173',
]);
});

it('lists and stops previews across all projects and workspaces', async () => {
const context = createService();
const local = await context.service.registerDetectedTarget({
projectId: 'project-1',
workspaceId: 'workspace-1',
transport: 'local',
source: { kind: 'terminal-output', terminalId: 'terminal-1' },
protocol: 'http:',
host: 'localhost',
port: 5173,
urlPath: '/',
});
const forwarded = await context.service.registerDetectedTarget({
projectId: 'project-2',
workspaceId: 'workspace-2',
connectionId: 'connection-1',
transport: 'ssh',
proxy: fakeProxy(),
source: { kind: 'terminal-output', terminalId: 'terminal-2' },
protocol: 'http:',
port: 5174,
urlPath: '/',
});

expect(context.service.listAll()).toEqual([local, forwarded]);

await context.service.stopAll();

expect(context.service.listAll()).toEqual([]);
expect(context.events).toContainEqual({ type: 'remove', id: local.id });
expect(context.events).toContainEqual({ type: 'remove', id: forwarded.id });
expect(context.closedTunnelIds).toEqual([
'preview:ssh:auto:project-2:workspace-2:connection-1:5174',
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ export class PreviewServerService {
);
}

listAll(): PreviewServer[] {
return Array.from(this.servers.values());
}

async handleTerminalSourceClosed(input: TerminalSourceClosedInput): Promise<void> {
if (input.transport === 'local') {
await this.stopForTerminal(input);
Expand Down Expand Up @@ -370,6 +374,11 @@ export class PreviewServerService {
await Promise.all(ids.map((id) => this.stop(id)));
}

async stopAll(): Promise<void> {
const ids = Array.from(this.servers.keys());
await Promise.all(ids.map((id) => this.stop(id)));
}

private registerLocalTarget(
target: Extract<RegisterDetectedPreviewTarget, { transport: 'local' }>
): DirectPreviewServer {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { ExternalLinkIcon, GlobeIcon, SquareIcon } from 'lucide-react';
import { observer } from 'mobx-react-lite';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import {
getProjectStore,
projectDisplayName,
} from '@renderer/features/projects/stores/project-selectors';
import {
formatPreviewServerLabel,
previewServerStatusLabel,
} from '@renderer/features/tasks/components/preview-servers/preview-server-format';
import {
asProvisioned,
getTaskManagerStore,
taskDisplayName,
} from '@renderer/features/tasks/stores/task-selectors';
import { events, rpc } from '@renderer/lib/ipc';
import { useShowModal } from '@renderer/lib/modal/modal-provider';
import { Badge } from '@renderer/lib/ui/badge';
import { Button } from '@renderer/lib/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@renderer/lib/ui/tooltip';
import { cn } from '@renderer/utils/utils';
import { previewServerEventChannel } from '@shared/core/preview-servers/events';
import type { PreviewServer } from '@shared/core/preview-servers/types';
import { previewServerUrl } from '@shared/core/preview-servers/types';

export const PreviewServersSettingsCard = observer(function PreviewServersSettingsCard() {
const [servers, setServers] = useState<PreviewServer[]>([]);
const showConfirm = useShowModal('confirmActionModal');

useEffect(() => {
let disposed = false;
void rpc.previewServers.listAll().then((list) => {
if (!disposed) setServers(sortServers(list));
});
const unsubscribe = events.on(previewServerEventChannel, (event) => {
setServers((current) => {
const next = new Map(current.map((server) => [server.id, server]));
if (event.type === 'upsert') {
next.set(event.server.id, event.server);
} else {
next.delete(event.id);
}
return sortServers(Array.from(next.values()));
});
});
return () => {
disposed = true;
unsubscribe();
};
}, []);

return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h3 className="text-sm font-normal text-foreground">Preview servers</h3>
<p className="text-xs text-foreground-passive">
Detected servers and port forwards across all projects and tasks.
</p>
</div>
{servers.length > 0 ? (
<Button
type="button"
variant="ghost"
className="hover:bg-destructive/10 text-foreground-destructive hover:text-foreground-destructive"
onClick={() => {
const count = servers.length;
showConfirm({
title: 'Stop preview servers',
description: `This will stop ${count === 1 ? 'the running preview server' : `all ${count} running preview servers`}.`,
confirmLabel: 'Stop',
variant: 'destructive',
onSuccess: () => {
void rpc.previewServers.stopAll();
},
});
}}
>
<SquareIcon className="size-3 fill-current" />
Stop all
</Button>
) : null}
</div>

{servers.length === 0 ? (
<div className="bg-muted/10 flex min-h-32 flex-col items-center justify-center rounded-lg border border-border p-8 text-center">
<GlobeIcon className="mb-3 size-8 text-foreground-passive" />
<div className="text-sm text-foreground">No preview servers running</div>
<p className="mt-1 max-w-sm text-xs text-foreground-passive">
Servers detected in task terminals and SSH port forwards show up here.
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{servers.map((server) => (
<PreviewServerSettingsRow key={server.id} server={server} />
))}
</div>
)}
</div>
);
});

function ServerActionButton({
label,
children,
disabled,
className,
onClick,
}: {
label: string;
children: ReactNode;
disabled?: boolean;
className?: string;
onClick: () => void;
}) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className={className}
onClick={onClick}
disabled={disabled}
aria-label={label}
>
{children}
</Button>
}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

function PreviewServerStatusBadge({ server }: { server: PreviewServer }) {
const kind = server.status.kind;
const isBusy = kind === 'starting' || kind === 'reconnecting';

return (
<Badge
variant={kind === 'failed' ? 'destructive' : 'secondary'}
className={cn(
'gap-1.5',
kind === 'ready' && 'text-foreground-success',
isBusy && 'text-foreground-warning'
)}
>
<span
className={cn(
'size-1.5 rounded-full bg-foreground-muted',
kind === 'ready' && 'bg-foreground-success',
isBusy && 'bg-foreground-warning',
kind === 'failed' && 'bg-destructive'
)}
/>
{previewServerStatusLabel(server)}
</Badge>
);
}

const PreviewServerSettingsRow = observer(function PreviewServerSettingsRow({
server,
}: {
server: PreviewServer;
}) {
const url = previewServerUrl(server);
const canOpen = server.status.kind === 'ready' && url !== null;
const projectName = projectDisplayName(getProjectStore(server.projectId));
const taskName = taskNameForWorkspace(server.projectId, server.workspaceId);
Comment thread
janburzinski marked this conversation as resolved.
const usedBy = [projectName, taskName].filter(Boolean).join(' · ');
const label = formatPreviewServerLabel(server);

return (
<div className="flex min-w-0 items-start gap-4 rounded-lg border border-border bg-background p-4">
<div className="bg-muted flex size-9 shrink-0 items-center justify-center rounded-md text-foreground-muted">
<GlobeIcon className="size-4" />
</div>
<div className="grid min-w-0 flex-1 gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h4 className="min-w-0 truncate text-sm font-medium text-foreground">{label}</h4>
<PreviewServerStatusBadge server={server} />
</div>
<div className="min-w-0 space-y-1 text-xs text-foreground-passive">
<p className="truncate">{url ?? 'No local URL'}</p>
{usedBy !== '' ? <p className="truncate">Used by: {usedBy}</p> : null}
{server.status.kind === 'failed' ? (
<p className="truncate text-foreground-destructive">{server.status.message}</p>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<ServerActionButton
label="Open in browser"
disabled={!canOpen}
onClick={() => {
if (canOpen) void rpc.app.openExternal(url);
}}
>
<ExternalLinkIcon className="size-4" />
</ServerActionButton>
<ServerActionButton
label={`Stop ${label}`}
className="hover:bg-destructive/10 text-foreground-destructive hover:text-foreground-destructive"
onClick={() => void rpc.previewServers.stop(server.id)}
>
<SquareIcon className="size-3 fill-current" />
</ServerActionButton>
</div>
</div>
);
});

function taskNameForWorkspace(projectId: string, workspaceId: string): string | undefined {
const manager = getTaskManagerStore(projectId);
if (!manager) return undefined;
for (const store of manager.tasks.values()) {
if (asProvisioned(store)?.workspaceId === workspaceId) return taskDisplayName(store);
}
return undefined;
}

function sortServers(servers: PreviewServer[]): PreviewServer[] {
return [...servers].sort((a, b) => {
if (a.projectId !== b.projectId) return a.projectId.localeCompare(b.projectId);
const aPort = a.kind === 'forwarded' ? a.remotePort : a.port;
const bPort = b.kind === 'forwarded' ? b.remotePort : b.port;
if (aPort !== bPort) return aPort - bPort;
return a.id.localeCompare(b.id);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import IntegrationsCard from './IntegrationsCard';
import InterfaceSettingsCard from './InterfaceSettingsCard';
import KeyboardSettingsCard from './KeyboardSettingsCard';
import NotificationSettingsCard from './NotificationSettingsCard';
import { PreviewServersSettingsCard } from './PreviewServersSettingsCard';
import RepositorySettingsCard from './RepositorySettingsCard';
import ResourceMonitorSettingsCard from './ResourceMonitorSettingsCard';
import SidebarMetadataSettingsCard from './SidebarMetadataSettingsCard';
Expand Down Expand Up @@ -89,9 +90,10 @@ function ConnectionsSettingsPage() {
<PageHeader
sticky
title="Connections"
description="Manage reusable SSH connections for remote projects."
description="Manage reusable SSH connections and running preview servers."
/>
<SshConnectionsSettingsCard />
<PreviewServersSettingsCard />
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PreviewServer } from '@shared/core/preview-servers/types';
import type { PreviewServer, PreviewServerStatus } from '@shared/core/preview-servers/types';

export function formatPreviewUrl(url: string): string {
try {
Expand Down Expand Up @@ -32,7 +32,11 @@ export function previewServerStatusLabel(server: PreviewServer): string {
}

export function previewServerStatusClasses(server: PreviewServer): string {
switch (server.status.kind) {
return previewServerStatusKindClasses(server.status.kind);
}

export function previewServerStatusKindClasses(kind: PreviewServerStatus['kind']): string {
switch (kind) {
case 'ready':
return 'bg-background-info text-foreground-info hover:bg-background-info-hover';
case 'starting':
Expand All @@ -42,3 +46,12 @@ export function previewServerStatusClasses(server: PreviewServer): string {
return 'bg-background-destructive text-foreground-destructive hover:bg-destructive/20';
}
}

export function previewServersSummaryStatusKind(
servers: PreviewServer[]
): PreviewServerStatus['kind'] {
if (servers.some((server) => server.status.kind === 'failed')) return 'failed';
if (servers.some((server) => server.status.kind === 'reconnecting')) return 'reconnecting';
if (servers.some((server) => server.status.kind === 'starting')) return 'starting';
return 'ready';
}
Loading
Loading