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
2 changes: 1 addition & 1 deletion ui/chen/composables/useChenSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe("chen session dialogs", () => {
await Promise.resolve();
socket.onclose?.({ code: 1006, reason: "" } as CloseEvent);

expect(markDisconnected).toHaveBeenCalled();
expect(markDisconnected).toHaveBeenCalledWith(expect.any(String), { dismissible: true });
});

it("marks a session as disconnected when its socket closes before ready", async () => {
Expand Down
9 changes: 7 additions & 2 deletions ui/chen/composables/useChenSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { resolveChenSessionCloseFatal } from "~/chen/utils/chenSessionClose";
interface UseChenSessionOptions {
authenticate: () => Promise<string>;
markConnected: () => void;
markDisconnected: (reason: string) => void;
markDisconnected: (reason: string, options?: { dismissible?: boolean }) => void;
onBeforeReady: () => Promise<void>;
onAfterReady: () => Promise<void>;
onDisconnected: () => void;
Expand Down Expand Up @@ -53,13 +53,17 @@ export function useChenSession(options: UseChenSessionOptions) {

let bootstrapGeneration = 0;
let fatalNotified = false;
let socketOpened = false;
let preparingReadyGeneration: number | null = null;

const sessionConnection = useChenWebSocket({
path: "session",
createSocket: options.createSocket,
resolveUrl: options.resolveUrl,
readyTimeoutMs: options.readyTimeoutMs,
onOpen: () => {
socketOpened = true;
},
onPacket: handlePacket,
onError: handleSocketError
});
Expand All @@ -78,7 +82,7 @@ export function useChenSession(options: UseChenSessionOptions) {
loading.value = false;
error.value = message;
errorReason.value = reason;
options.markDisconnected(message);
options.markDisconnected(message, { dismissible: socketOpened });

// A Chen session owns all of its consoles. Close dependent consoles first
// so their backend close handlers can still resolve the active session.
Expand Down Expand Up @@ -197,6 +201,7 @@ export function useChenSession(options: UseChenSessionOptions) {
const currentGeneration = ++bootstrapGeneration;
sessionConnection.close();
fatalNotified = false;
socketOpened = false;
ready.value = false;
loading.value = true;
error.value = "";
Expand Down
2 changes: 1 addition & 1 deletion ui/chen/workspaces/DatabaseSessionSurface.vue
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ const session = useChenSession({
authenticate: auth.authenticate,
translate: t,
markConnected: () => markSessionConnected(props.tab.id),
markDisconnected: (reason) => markSessionDisconnected(props.tab.id, reason),
markDisconnected: (reason, options) => markSessionDisconnected(props.tab.id, reason, options),
onBeforeReady: async () => {
await auth.loadProfile();
await tree.loadNodeChildren(null);
Expand Down
17 changes: 17 additions & 0 deletions ui/components/Workspace/connectionProgressOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import overlay from "./connectionProgressOverlay.vue?raw";

describe("workspace connection progress overlay", () => {
it("allows post-connection failures to reveal the underlying connector output", () => {
expect(overlay).toContain("dismissible?: boolean");
expect(overlay).toContain('v-show="!dismissed"');
expect(overlay).toContain('v-if="failed && dismissible"');
expect(overlay).toContain('class="absolute top-6 right-6 z-10"');
expect(overlay).toContain('@click="dismiss"');
});

it("preserves normalized diagnostic line breaks", () => {
expect(overlay).toContain("whitespace-pre-wrap");
expect(overlay).toContain("wrap-break-word");
});
});
32 changes: 30 additions & 2 deletions ui/components/Workspace/connectionProgressOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
import type { WorkspaceConnectionProgressStage } from "~/composables/useWorkspaceTabs";
import { isFaceLiveHostMessage } from "~/utils/faceLive";

const props = defineProps<{ error?: string; paneId?: string; stage: WorkspaceConnectionProgressStage }>();
const props = defineProps<{
dismissible?: boolean;
error?: string;
paneId?: string;
stage: WorkspaceConnectionProgressStage;
}>();
const emit = defineEmits<{ cancel: []; edit: []; reconnect: [] }>();

const { t } = useI18n();
Expand Down Expand Up @@ -45,11 +50,16 @@ function iconColor(index: number) {
}

const settleStep3 = ref(false);
const dismissed = ref(false);
const playId = ref(0);
const wipeOn = ref(false);
const lateOn = ref(false);
let lateTimer: ReturnType<typeof setTimeout> | undefined;

function dismiss() {
dismissed.value = true;
}

function replayWipe() {
playId.value++;
wipeOn.value = false;
Expand Down Expand Up @@ -93,6 +103,9 @@ watch([stageIndex, failed, waiting], ([value, isFailed]) => {
watch(failed, (isFailed, wasFailed) => {
if (isFailed !== wasFailed) replayWipe();
});
watch([() => props.error, () => props.stage], () => {
dismissed.value = false;
});
watch(
() => [group.value?.id, group.value?.code, group.value?.submitted],
() => {
Expand Down Expand Up @@ -152,10 +165,21 @@ async function reconnect() {

<template>
<div
v-show="!dismissed"
:role="failed ? 'alert' : 'status'"
aria-live="polite"
class="absolute inset-0 z-20 grid place-items-center bg-[color-mix(in_srgb,var(--workspace-surface-background)_80%,transparent)] p-6 backdrop-blur-sm"
>
<UButton
v-if="failed && dismissible"
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="sm"
:aria-label="t('Common.Close')"
class="absolute top-6 right-6 z-10"
@click="dismiss"
/>
<div class="relative w-full max-w-lg">
<div class="flex w-full items-center gap-5" aria-hidden="true">
<span class="relative shrink-0">
Expand Down Expand Up @@ -214,7 +238,11 @@ async function reconnect() {
</span>
</div>
<div class="absolute inset-x-0 top-full mt-4 flex flex-col items-center gap-4">
<p v-if="copy" :key="`copy-${playId}`" class="error-copy max-w-lg text-center text-sm text-default">
<p
v-if="copy"
:key="`copy-${playId}`"
class="error-copy max-h-40 max-w-lg overflow-y-auto text-center text-sm text-default whitespace-pre-wrap wrap-break-word"
>
{{ copy }}
</p>
<p
Expand Down
1 change: 1 addition & 0 deletions ui/components/Workspace/sessionPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ onBeforeUnmount(() => {
v-if="pane.connectionProgress && pane.mode !== 'setup'"
:pane-id="pane.id"
:stage="pane.connectionProgress"
:dismissible="pane.connectionFailureDismissible"
:error="
pane.status === 'connecting' || pane.status === 'connected' || pane.status === 'ready'
? undefined
Expand Down
11 changes: 8 additions & 3 deletions ui/composables/useWorkspaceTabs.sessionStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ describe("workspace session disconnect status", () => {
expect(tabs.tabs.value[0]?.status).toBe("disconnected");
});

it("keeps a ready session and its disconnect reason", () => {
it("keeps a ready session and a safe disconnect reason", () => {
const pane = tabs.openSession(asset, { protocol: "ssh", account: "root", newTab: true });
pane.payload = { id: "token" };
tabs.markSessionConnected(pane.id);

tabs.markSessionDisconnected(pane.id, "connection closed");
tabs.markSessionDisconnected(pane.id, "\x1B[31mconnection closed\x1B[0m");

expect(pane.status).toBe("disconnected");
expect(pane.payload).toEqual({ id: "token" });
Expand All @@ -66,11 +66,16 @@ describe("workspace session disconnect status", () => {
const pane = tabs.openSetupSession(asset);
tabs.startSessionConnection(pane.id, { protocol: "ssh", account: "root" });
tabs.markSessionConnected(pane.id);
tabs.markSessionDisconnected(pane.id, "Koko 已结束会话:连接建立失败");
tabs.markSessionDisconnected(pane.id, "Koko 已结束会话:连接建立失败", { dismissible: true });

expect(pane.status).toBe("disconnected");
expect(pane.connectionProgress).toBe("connected");
expect(pane.connectionFailure).toBe("Koko 已结束会话:连接建立失败");
expect(pane.connectionFailureDismissible).toBe(true);
expect(tabs.tabs.value[0]?.connectionFailureDismissible).toBe(true);

tabs.markSessionConnecting(pane.id);
expect(pane.connectionFailureDismissible).toBeUndefined();
});

it("increments the connection attempt before a new request can update the pane", () => {
Expand Down
6 changes: 5 additions & 1 deletion ui/composables/useWorkspaceTabs.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { ConnectionFormDraft } from "~/composables/useConnectionFormState";
import type { SavedSessionGroup } from "~/composables/useSavedSessionGroups";
import type { WebProxyOpenRequest } from "~/composables/useWebProxyManager";
import type { WorkspaceConnectionProgressStage } from "~/composables/workspaceConnectionProgress";
import type { AssetItem, PermedAccount, PermedProtocol, TokenResponse } from "~/types";

import { createSharedComposable, useFullscreen } from "@vueuse/core";
import { closeAclScope } from "~/composables/useAclDialog";
import { useRecentConnections } from "~/composables/useRecentConnections";
import type { SavedSessionGroup } from "~/composables/useSavedSessionGroups";
import {
getSessionGroupStorageKey,
persistSessionGroup,
Expand Down Expand Up @@ -65,6 +65,7 @@ export interface WorkspaceSurfaceSession {
connectionProgress?: WorkspaceConnectionProgressStage;
resumeSetupOnFailure?: boolean;
connectionFailure?: string;
connectionFailureDismissible?: boolean;
setupDraft?: ConnectionFormDraft;
payload?: WorkspaceSessionPayload;
setupAsset?: AssetItem;
Expand Down Expand Up @@ -202,6 +203,7 @@ const blankSurface = (): Omit<WorkspaceSurfaceSession, "id"> => ({
connectionProgress: undefined,
resumeSetupOnFailure: undefined,
connectionFailure: undefined,
connectionFailureDismissible: undefined,
setupDraft: undefined,
payload: undefined,
setupAsset: undefined
Expand Down Expand Up @@ -249,6 +251,7 @@ const syncTabFromPrimaryPane = (tab: WorkspaceSessionTab) => {
tab.connectionProgress = primaryPane.connectionProgress;
tab.resumeSetupOnFailure = primaryPane.resumeSetupOnFailure;
tab.connectionFailure = primaryPane.connectionFailure;
tab.connectionFailureDismissible = primaryPane.connectionFailureDismissible;
tab.setupDraft = primaryPane.setupDraft;
tab.payload = primaryPane.payload;
tab.setupAsset = primaryPane.setupAsset;
Expand Down Expand Up @@ -1097,6 +1100,7 @@ export const useWorkspaceTabs = () => {

found.pane.payload = payload;
found.pane.connectionFailure = undefined;
found.pane.connectionFailureDismissible = undefined;
found.pane.connectMethod = String(payload.connectMethod?.value || found.pane.connectMethod || "") || undefined;
found.pane.status = "ready";
found.pane.mode = "session";
Expand Down
21 changes: 18 additions & 3 deletions ui/composables/workspaceConnectionProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import type { ConnectionFormDraft } from "~/composables/useConnectionFormState";
import type { WorkspacePane, WorkspaceSessionTab } from "~/composables/useWorkspaceTabs";
import type { PermedAccount } from "~/types";
import { clearWorkspaceSessionDetails } from "~/composables/useWorkspaceSessionDetails";
import { normalizeConnectionFailure } from "~/utils/connectionFailure";

export type WorkspaceConnectionProgressStage = "token" | "session" | "connected";

export interface WorkspaceConnectionFailureOptions {
dismissible?: boolean;
}

type SessionMatch = { tabId?: string; assetId: string; protocol: string; account: string };
type ProgressMatch = { tab: WorkspaceSessionTab; pane: WorkspacePane; paneIndex: number };

Expand Down Expand Up @@ -87,6 +92,7 @@ export function createWorkspaceConnectionProgress(host: {
connectionAttempts.set(paneId, (connectionAttempts.get(paneId) || 0) + 1);
match.pane.status = "connecting";
match.pane.connectionFailure = undefined;
match.pane.connectionFailureDismissible = undefined;
match.pane.mode = "session";
if (match.pane.connectionProgress) assignConnectionProgress(match.pane, "token", match.tab, match.paneIndex);
host.setActivePaneId(paneId);
Expand All @@ -108,6 +114,7 @@ export function createWorkspaceConnectionProgress(host: {
if (connection.permedAccounts) match.pane.permedAccounts = connection.permedAccounts;
match.pane.payload = undefined;
match.pane.connectionFailure = undefined;
match.pane.connectionFailureDismissible = undefined;
match.pane.status = "connecting";
match.pane.resumeSetupOnFailure = Boolean(setupDraft);
match.pane.setupDraft = setupDraft
Expand Down Expand Up @@ -135,7 +142,8 @@ export function createWorkspaceConnectionProgress(host: {

const inProgress = Boolean(found.pane.connectionProgress);
found.pane.connectedAt = undefined;
found.pane.connectionFailure = reason;
found.pane.connectionFailure = normalizeConnectionFailure(reason) || undefined;
found.pane.connectionFailureDismissible = undefined;
clearWorkspaceSessionDetails(found.pane.id);
host.closeNativeSession(found.pane.id);
if (inProgress) {
Expand Down Expand Up @@ -164,6 +172,7 @@ export function createWorkspaceConnectionProgress(host: {
if (!match) return;

clearConnectionProgress(match.pane);
match.pane.connectionFailureDismissible = undefined;
if (match.pane.setupAsset) {
match.pane.payload = undefined;
match.pane.status = "selecting";
Expand All @@ -182,6 +191,7 @@ export function createWorkspaceConnectionProgress(host: {
match.pane.resumeSetupOnFailure = false;
match.pane.setupDraft = undefined;
match.pane.connectionFailure = undefined;
match.pane.connectionFailureDismissible = undefined;
match.pane.status = "connected";
match.pane.connectedAt = Date.now();
match.pane.mode = "session";
Expand All @@ -194,15 +204,20 @@ export function createWorkspaceConnectionProgress(host: {
if (match.paneIndex === 0) host.syncTabFromPrimaryPane(match.tab);
};

const markSessionDisconnected = (paneId: string, reason?: string) => {
const markSessionDisconnected = (
paneId: string,
reason?: string,
options: WorkspaceConnectionFailureOptions = {}
) => {
const match = host.findPane(paneId);
if (!match) return;

stopConnectionProgressTimer(match.pane.id);
connectionProgressGoal.delete(match.pane.id);
match.pane.connectionProgress = "connected";
match.pane.status = "disconnected";
match.pane.connectionFailure = reason;
match.pane.connectionFailure = normalizeConnectionFailure(reason) || undefined;
match.pane.connectionFailureDismissible = Boolean(match.pane.connectionFailure && options.dismissible);
if (match.paneIndex === 0) host.syncTabFromPrimaryPane(match.tab);
};

Expand Down
4 changes: 2 additions & 2 deletions ui/koko/components/FileManagement/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const props = defineProps<{
const emit = defineEmits<{
capabilities: [capabilities: SftpCapabilities | null];
connectionChange: [connected: boolean];
connectionFailure: [message: string];
connectionFailure: [message: string, dismissible: boolean];
}>();

const { t } = useI18n();
Expand Down Expand Up @@ -186,7 +186,7 @@ function setLocalPaneRef(value: SftpLocalPaneHandle | null): void {
:start-tour="sftpTour.start"
:set-primary-pane-ref="setPrimaryPaneRef"
@connection-change="emit('connectionChange', $event)"
@connection-failure="emit('connectionFailure', $event)"
@connection-failure="(message, dismissible) => emit('connectionFailure', message, dismissible)"
/>
<SftpConnectModal :workspace="workspace" />
<SftpSendModal :transfer="transfer" />
Expand Down
6 changes: 4 additions & 2 deletions ui/koko/components/FileManagement/pane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const emit = defineEmits<{
transferEndpointConnected: [];
transferEndpointUnmounted: [endpoint: FileTransferEndpointRef];
connectionChange: [connected: boolean];
connectionFailure: [message: string];
connectionFailure: [message: string, dismissible: boolean];
focus: [];
addRemote: [];
startTour: [];
Expand Down Expand Up @@ -409,7 +409,9 @@ watch(manager.currentPath, () => {
});
watch(manager.ready, (ready) => emit("connectionChange", Boolean(ready)), { immediate: true });
watch(manager.fatalError, (fatalError) => {
if (fatalError) emit("connectionFailure", manager.error.value || t("koko.fileManagement.expired"));
if (fatalError) {
emit("connectionFailure", manager.error.value || t("koko.fileManagement.expired"), manager.hasConnected.value);
}
});
watch([manager.connected, manager.loading, manager.fatalError], ([connected, loading, fatalError]) => {
if (!connected || fatalError) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const props = defineProps<{

const emit = defineEmits<{
connectionChange: [connected: boolean];
connectionFailure: [message: string];
connectionFailure: [message: string, dismissible: boolean];
}>();
const { t } = useI18n();

Expand Down Expand Up @@ -220,7 +220,7 @@ const remoteOverflowItems = computed<DropdownMenuItem[][]>(() => [
@transfer-endpoint-connected="connectTransferEndpoint"
@transfer-endpoint-unmounted="unmountTransferEndpoint"
@connection-change="handlePrimaryConnectionChange"
@connection-failure="emit('connectionFailure', $event)"
@connection-failure="(message, dismissible) => emit('connectionFailure', message, dismissible)"
@add-remote="openRemoteConnect()"
@start-tour="startTour"
/>
Expand Down
6 changes: 4 additions & 2 deletions ui/koko/components/SftpIde/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { useSftpIdeWorkspace } from "./useSftpIdeWorkspace";
const props = defineProps<{ sftpToken: string; workspaceKey?: string }>();
const emit = defineEmits<{
connectionChange: [connected: boolean];
connectionFailure: [message: string];
connectionFailure: [message: string, dismissible: boolean];
}>();
const { t } = useI18n();
const toast = useToast();
Expand All @@ -43,7 +43,9 @@ const context = computed<ConnectorSessionContext | null>(() => {
const manager = useSftpFileManager(context);
watch(manager.ready, (ready) => emit("connectionChange", Boolean(ready)), { immediate: true });
watch(manager.fatalError, (fatalError) => {
if (fatalError) emit("connectionFailure", manager.error.value || t("koko.fileManagement.expired"));
if (fatalError) {
emit("connectionFailure", manager.error.value || t("koko.fileManagement.expired"), manager.hasConnected.value);
}
});
const fileEditorCapability = computed(() => manager.capabilities.value?.file_editor || null);
const fileEditorSupported = computed(() => {
Expand Down
Loading
Loading