889 lines
24 KiB
TypeScript
889 lines
24 KiB
TypeScript
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
|
import type { EventSourceMessage } from '@microsoft/fetch-event-source';
|
|
import { useStorage } from '@vueuse/core';
|
|
import { computed, ref } from 'vue';
|
|
|
|
import type { TerminalSessionItem, TerminalSessionsResponse } from '~/types';
|
|
import { parse_api_error, parse_api_response, request, uri } from '~/utils';
|
|
|
|
type ConsoleSessionStatus =
|
|
| 'idle'
|
|
| 'starting'
|
|
| 'running'
|
|
| 'reconnecting'
|
|
| 'finished'
|
|
| 'interrupted'
|
|
| 'expired'
|
|
| 'error';
|
|
|
|
type ConsoleSessionState = {
|
|
sessionId: string | null;
|
|
command: string;
|
|
status: ConsoleSessionStatus;
|
|
lastEventId: string | null;
|
|
exitCode: number | null;
|
|
transcript: Array<string>;
|
|
error: string;
|
|
};
|
|
|
|
type ConsoleSessionResponse = {
|
|
session_id?: string | null;
|
|
sessionId?: string | null;
|
|
command?: string | null;
|
|
status?: string | null;
|
|
last_event_id?: string | number | null;
|
|
lastEventId?: string | number | null;
|
|
exit_code?: number | null;
|
|
exitCode?: number | null;
|
|
expired?: boolean | null;
|
|
not_found?: boolean | null;
|
|
};
|
|
|
|
type StartConsoleSessionInput = {
|
|
command: string;
|
|
displayCommand?: string;
|
|
};
|
|
|
|
type CancelConsoleSessionResult = {
|
|
status: 'cancelled' | 'missing' | 'error';
|
|
message?: string;
|
|
};
|
|
|
|
type RecentSessionItem = {
|
|
sessionId: string;
|
|
command: string;
|
|
status: 'starting' | 'running' | 'completed';
|
|
createdAt: number | null;
|
|
startedAt: number | null;
|
|
finishedAt: number | null;
|
|
expiresAt: number | null;
|
|
availableUntil: number | null;
|
|
exitCode: number | null;
|
|
lastSequence: number;
|
|
};
|
|
|
|
const STORAGE_KEY = 'console_session_state';
|
|
const HIDDEN_RECENT_SESSIONS_KEY = 'console_hidden_recent_sessions';
|
|
const MAX_TRANSCRIPT_CHUNKS = 1500;
|
|
const MAX_TRANSCRIPT_CHARS = 120000;
|
|
const RECONNECT_DELAY_MS = 1500;
|
|
const RECENT_SESSIONS_MAX = 30;
|
|
|
|
const DEFAULT_STATE: ConsoleSessionState = {
|
|
sessionId: null,
|
|
command: '',
|
|
status: 'idle',
|
|
lastEventId: null,
|
|
exitCode: null,
|
|
transcript: [],
|
|
error: '',
|
|
};
|
|
|
|
class ConsoleSessionExpiredError extends Error {}
|
|
|
|
const trimTranscript = (transcript: Array<string>): Array<string> => {
|
|
const next = [...transcript];
|
|
|
|
while (next.length > MAX_TRANSCRIPT_CHUNKS) {
|
|
next.shift();
|
|
}
|
|
|
|
let totalChars = next.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
while (next.length > 1 && totalChars > MAX_TRANSCRIPT_CHARS) {
|
|
totalChars -= next[0]?.length || 0;
|
|
next.shift();
|
|
}
|
|
|
|
return next;
|
|
};
|
|
|
|
const isActiveSessionStatus = (status: ConsoleSessionStatus | null | undefined): boolean => {
|
|
return ['starting', 'running', 'reconnecting'].includes(status || '');
|
|
};
|
|
|
|
const normalizeStatus = (
|
|
status: string | null | undefined,
|
|
fallback: ConsoleSessionStatus = 'idle',
|
|
): ConsoleSessionStatus => {
|
|
switch ((status || '').trim().toLowerCase()) {
|
|
case 'starting':
|
|
case 'pending':
|
|
case 'queued':
|
|
return 'starting';
|
|
|
|
case 'active':
|
|
case 'open':
|
|
case 'running':
|
|
case 'streaming':
|
|
return 'running';
|
|
|
|
case 'reconnecting':
|
|
return 'reconnecting';
|
|
|
|
case 'complete':
|
|
case 'completed':
|
|
case 'closed':
|
|
case 'done':
|
|
case 'exited':
|
|
case 'finished':
|
|
return 'finished';
|
|
|
|
case 'interrupted':
|
|
return 'interrupted';
|
|
|
|
case 'failed':
|
|
case 'error':
|
|
return 'error';
|
|
|
|
case 'expired':
|
|
case 'missing':
|
|
case 'not_found':
|
|
return 'expired';
|
|
|
|
case 'idle':
|
|
return 'idle';
|
|
|
|
default:
|
|
return fallback;
|
|
}
|
|
};
|
|
|
|
const normalizePersistedState = (
|
|
input: Partial<ConsoleSessionState> | null | undefined,
|
|
{ allowActiveWithoutSessionId = false }: { allowActiveWithoutSessionId?: boolean } = {},
|
|
): ConsoleSessionState => {
|
|
const sessionId =
|
|
typeof input?.sessionId === 'string' && input.sessionId.trim() ? input.sessionId : null;
|
|
const status = normalizeStatus(input?.status, DEFAULT_STATE.status);
|
|
const hasDetachedActiveStatus = isActiveSessionStatus(status) && sessionId === null;
|
|
|
|
return {
|
|
sessionId,
|
|
command: typeof input?.command === 'string' ? input.command : DEFAULT_STATE.command,
|
|
status: hasDetachedActiveStatus && !allowActiveWithoutSessionId ? 'idle' : status,
|
|
lastEventId:
|
|
input?.lastEventId === null || input?.lastEventId === undefined
|
|
? null
|
|
: String(input.lastEventId),
|
|
exitCode: typeof input?.exitCode === 'number' ? input.exitCode : null,
|
|
transcript: trimTranscript(
|
|
Array.isArray(input?.transcript)
|
|
? input.transcript.filter((chunk): chunk is string => typeof chunk === 'string')
|
|
: DEFAULT_STATE.transcript,
|
|
),
|
|
error: typeof input?.error === 'string' ? input.error : DEFAULT_STATE.error,
|
|
};
|
|
};
|
|
|
|
const sessionState = useStorage<ConsoleSessionState>(STORAGE_KEY, { ...DEFAULT_STATE });
|
|
const hiddenRecentSessions = useStorage<Array<string>>(HIDDEN_RECENT_SESSIONS_KEY, []);
|
|
const recentSessions = ref<Array<RecentSessionItem>>([]);
|
|
const streamController = ref<AbortController | null>(null);
|
|
const reconnectTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
|
const connectionNonce = ref(0);
|
|
|
|
sessionState.value = normalizePersistedState(sessionState.value);
|
|
|
|
const updateState = (patch: Partial<ConsoleSessionState>): void => {
|
|
sessionState.value = normalizePersistedState(
|
|
{
|
|
...sessionState.value,
|
|
...patch,
|
|
},
|
|
{ allowActiveWithoutSessionId: true },
|
|
);
|
|
};
|
|
|
|
const normalizeHiddenRecentSessions = (): Array<string> => {
|
|
return hiddenRecentSessions.value
|
|
.filter((value): value is string => typeof value === 'string')
|
|
.map((value) => value.trim())
|
|
.filter((value) => Boolean(value));
|
|
};
|
|
|
|
const setHiddenRecentSessions = (items: Array<string>): void => {
|
|
hiddenRecentSessions.value = Array.from(new Set(items));
|
|
};
|
|
|
|
const isRecentSessionHidden = (sessionId: string): boolean => {
|
|
return normalizeHiddenRecentSessions().includes(sessionId);
|
|
};
|
|
|
|
const sortRecentSessions = (items: Array<RecentSessionItem>): Array<RecentSessionItem> => {
|
|
return items.slice().sort((left, right) => {
|
|
const leftTime = left.finishedAt ?? left.startedAt ?? left.createdAt ?? 0;
|
|
const rightTime = right.finishedAt ?? right.startedAt ?? right.createdAt ?? 0;
|
|
return rightTime - leftTime;
|
|
});
|
|
};
|
|
|
|
const trimRecentSessions = (items: Array<RecentSessionItem>): Array<RecentSessionItem> => {
|
|
return items.slice(0, RECENT_SESSIONS_MAX);
|
|
};
|
|
|
|
const setRecentSessions = (items: Array<RecentSessionItem>): void => {
|
|
recentSessions.value = trimRecentSessions(
|
|
sortRecentSessions(items.filter((item) => !isRecentSessionHidden(item.sessionId))),
|
|
);
|
|
};
|
|
|
|
const upsertRecentSession = (item: RecentSessionItem): void => {
|
|
if (isRecentSessionHidden(item.sessionId)) {
|
|
return;
|
|
}
|
|
|
|
setRecentSessions([
|
|
item,
|
|
...recentSessions.value.filter((entry) => entry.sessionId !== item.sessionId),
|
|
]);
|
|
};
|
|
|
|
const hideRecentSession = (sessionId: string): void => {
|
|
const normalized = sessionId.trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
|
|
setHiddenRecentSessions([...normalizeHiddenRecentSessions(), normalized]);
|
|
setRecentSessions(recentSessions.value.filter((item) => item.sessionId !== normalized));
|
|
};
|
|
|
|
const clearRecentSessions = (): void => {
|
|
setHiddenRecentSessions([
|
|
...normalizeHiddenRecentSessions(),
|
|
...recentSessions.value.map((item) => item.sessionId),
|
|
]);
|
|
recentSessions.value = [];
|
|
};
|
|
|
|
const appendTranscript = (chunk: string): void => {
|
|
if (!chunk) {
|
|
return;
|
|
}
|
|
|
|
updateState({ transcript: trimTranscript([...sessionState.value.transcript, chunk]) });
|
|
};
|
|
|
|
const stopReconnectTimer = (): void => {
|
|
if (!reconnectTimer.value) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(reconnectTimer.value);
|
|
reconnectTimer.value = null;
|
|
};
|
|
|
|
const stopStream = (): void => {
|
|
stopReconnectTimer();
|
|
streamController.value?.abort();
|
|
streamController.value = null;
|
|
};
|
|
|
|
const clearTranscript = (): void => {
|
|
const isActive = isActiveSessionStatus(sessionState.value.status);
|
|
|
|
updateState({
|
|
transcript: [],
|
|
sessionId: isActive ? sessionState.value.sessionId : null,
|
|
status: isActive ? sessionState.value.status : 'idle',
|
|
lastEventId: isActive ? sessionState.value.lastEventId : null,
|
|
exitCode: isActive ? sessionState.value.exitCode : null,
|
|
error: '',
|
|
});
|
|
};
|
|
|
|
const markExpired = (): void => {
|
|
stopStream();
|
|
updateState({ status: 'expired', error: '' });
|
|
syncCurrentSessionToRecentSessions();
|
|
};
|
|
|
|
const finishSession = (
|
|
status: ConsoleSessionStatus = 'finished',
|
|
exitCode: number | null = sessionState.value.exitCode,
|
|
): void => {
|
|
stopStream();
|
|
updateState({
|
|
status,
|
|
exitCode,
|
|
error: '',
|
|
});
|
|
syncCurrentSessionToRecentSessions();
|
|
};
|
|
|
|
const scheduleReconnect = (): void => {
|
|
if (!sessionState.value.sessionId) {
|
|
return;
|
|
}
|
|
|
|
stopReconnectTimer();
|
|
updateState({ status: 'reconnecting', error: '' });
|
|
reconnectTimer.value = setTimeout(() => {
|
|
reconnectTimer.value = null;
|
|
if (!sessionState.value.sessionId || !isActiveSessionStatus(sessionState.value.status)) {
|
|
return;
|
|
}
|
|
|
|
void connectStream();
|
|
}, RECONNECT_DELAY_MS);
|
|
};
|
|
|
|
const shouldSkipEvent = (eventId: string): boolean => {
|
|
const lastEventId = sessionState.value.lastEventId;
|
|
if (!lastEventId) {
|
|
return false;
|
|
}
|
|
|
|
if (eventId === lastEventId) {
|
|
return true;
|
|
}
|
|
|
|
if (/^\d+$/.test(eventId) && /^\d+$/.test(lastEventId)) {
|
|
return Number(eventId) <= Number(lastEventId);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const readJson = async (response: Response): Promise<unknown> => {
|
|
try {
|
|
return await response.clone().json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const parseResponseError = async (response: Response): Promise<string> => {
|
|
const payload = await readJson(response);
|
|
if (payload) {
|
|
return await parse_api_error(payload);
|
|
}
|
|
|
|
try {
|
|
const text = await response.text();
|
|
if (text) {
|
|
return text;
|
|
}
|
|
} catch {
|
|
return response.statusText || 'Request failed.';
|
|
}
|
|
|
|
return response.statusText || 'Request failed.';
|
|
};
|
|
|
|
const normalizeRecentSessionStatus = (
|
|
status: string | null | undefined,
|
|
): RecentSessionItem['status'] => {
|
|
switch (normalizeStatus(status, 'finished')) {
|
|
case 'starting':
|
|
return 'starting';
|
|
|
|
case 'running':
|
|
case 'reconnecting':
|
|
return 'running';
|
|
|
|
default:
|
|
return 'completed';
|
|
}
|
|
};
|
|
|
|
const recentSessionFromPayload = (payload: TerminalSessionItem): RecentSessionItem | null => {
|
|
if (typeof payload.session_id !== 'string' || !payload.session_id.trim()) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof payload.command !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
sessionId: payload.session_id,
|
|
command: payload.command,
|
|
status: normalizeRecentSessionStatus(payload.status),
|
|
createdAt: typeof payload.created_at === 'number' ? payload.created_at : null,
|
|
startedAt: typeof payload.started_at === 'number' ? payload.started_at : null,
|
|
finishedAt: typeof payload.finished_at === 'number' ? payload.finished_at : null,
|
|
expiresAt: typeof payload.expires_at === 'number' ? payload.expires_at : null,
|
|
availableUntil: typeof payload.available_until === 'number' ? payload.available_until : null,
|
|
exitCode: typeof payload.exit_code === 'number' ? payload.exit_code : null,
|
|
lastSequence: typeof payload.last_sequence === 'number' ? payload.last_sequence : 0,
|
|
};
|
|
};
|
|
|
|
const syncCurrentSessionToRecentSessions = (): void => {
|
|
if (!sessionState.value.sessionId || !sessionState.value.command.trim()) {
|
|
return;
|
|
}
|
|
|
|
const existing =
|
|
recentSessions.value.find((item) => item.sessionId === sessionState.value.sessionId) ?? null;
|
|
const isFinished = ['finished', 'interrupted', 'error', 'expired'].includes(
|
|
sessionState.value.status,
|
|
);
|
|
const lastSequence =
|
|
sessionState.value.lastEventId && /^\d+$/.test(sessionState.value.lastEventId)
|
|
? Number(sessionState.value.lastEventId)
|
|
: (existing?.lastSequence ?? 0);
|
|
|
|
upsertRecentSession({
|
|
sessionId: sessionState.value.sessionId,
|
|
command: sessionState.value.command,
|
|
status: normalizeRecentSessionStatus(sessionState.value.status),
|
|
createdAt: existing?.createdAt ?? null,
|
|
startedAt: existing?.startedAt ?? null,
|
|
finishedAt: isFinished
|
|
? (existing?.finishedAt ?? Date.now() / 1000)
|
|
: (existing?.finishedAt ?? null),
|
|
expiresAt: existing?.expiresAt ?? null,
|
|
availableUntil: existing?.availableUntil ?? null,
|
|
exitCode: sessionState.value.exitCode ?? existing?.exitCode ?? null,
|
|
lastSequence,
|
|
});
|
|
};
|
|
|
|
const fetchRecentSessions = async (): Promise<void> => {
|
|
try {
|
|
const response = await request('/api/system/terminal');
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
|
|
const payload = await parse_api_response<TerminalSessionsResponse>(response.json());
|
|
const items = Array.isArray(payload?.items)
|
|
? payload.items
|
|
.map((item) => recentSessionFromPayload(item))
|
|
.filter((item): item is RecentSessionItem => item !== null)
|
|
: [];
|
|
|
|
setRecentSessions(items);
|
|
syncCurrentSessionToRecentSessions();
|
|
} catch {
|
|
return;
|
|
}
|
|
};
|
|
|
|
const refreshSessionMetadata = async (): Promise<void> => {
|
|
if (!sessionState.value.sessionId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = await readSessionResponse(
|
|
`/api/system/terminal/${encodeURIComponent(sessionState.value.sessionId)}`,
|
|
);
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
|
|
updateState(normalizeResponse(payload));
|
|
syncCurrentSessionToRecentSessions();
|
|
} catch (error) {
|
|
if (error instanceof ConsoleSessionExpiredError) {
|
|
markExpired();
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
};
|
|
|
|
const normalizeResponse = (payload: ConsoleSessionResponse): Partial<ConsoleSessionState> => {
|
|
const status =
|
|
payload.expired || payload.not_found
|
|
? 'expired'
|
|
: normalizeStatus(payload.status, sessionState.value.status);
|
|
|
|
const sessionId = payload.session_id ?? payload.sessionId;
|
|
const lastEventId = payload.last_event_id ?? payload.lastEventId;
|
|
const exitCode = payload.exit_code ?? payload.exitCode;
|
|
|
|
return {
|
|
sessionId: sessionId === undefined ? sessionState.value.sessionId : sessionId,
|
|
command: payload.command ?? sessionState.value.command,
|
|
status,
|
|
lastEventId:
|
|
lastEventId === undefined || lastEventId === null
|
|
? sessionState.value.lastEventId
|
|
: String(lastEventId),
|
|
exitCode: exitCode === undefined ? sessionState.value.exitCode : exitCode,
|
|
error:
|
|
status === 'error'
|
|
? sessionState.value.error ||
|
|
(typeof exitCode === 'number' && exitCode !== 0
|
|
? `Command exited with code ${exitCode}.`
|
|
: '')
|
|
: '',
|
|
};
|
|
};
|
|
|
|
const readSessionResponse = async (
|
|
path: string,
|
|
{ allowMissing = false }: { allowMissing?: boolean } = {},
|
|
): Promise<ConsoleSessionResponse | null> => {
|
|
const response = await request(path);
|
|
|
|
if ([404, 410].includes(response.status)) {
|
|
if (allowMissing) {
|
|
return null;
|
|
}
|
|
|
|
throw new ConsoleSessionExpiredError(await parseResponseError(response));
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await parseResponseError(response));
|
|
}
|
|
|
|
return await parse_api_response<ConsoleSessionResponse>(response.json());
|
|
};
|
|
|
|
const connectStream = async (): Promise<void> => {
|
|
if (!sessionState.value.sessionId) {
|
|
return;
|
|
}
|
|
|
|
stopStream();
|
|
|
|
const controller = new AbortController();
|
|
let finalMetadataRefresh: Promise<void> | null = null;
|
|
const nonce = connectionNonce.value + 1;
|
|
connectionNonce.value = nonce;
|
|
streamController.value = controller;
|
|
|
|
const search = new URLSearchParams();
|
|
if (sessionState.value.lastEventId) {
|
|
search.set('since', sessionState.value.lastEventId);
|
|
}
|
|
|
|
const url = uri(
|
|
`/api/system/terminal/${encodeURIComponent(sessionState.value.sessionId)}/stream${search.size > 0 ? `?${search.toString()}` : ''}`,
|
|
);
|
|
|
|
const headers: Record<string, string> = {
|
|
Accept: 'text/event-stream',
|
|
};
|
|
|
|
if (sessionState.value.lastEventId) {
|
|
headers['Last-Event-ID'] = sessionState.value.lastEventId;
|
|
}
|
|
|
|
try {
|
|
await fetchEventSource(url, {
|
|
method: 'GET',
|
|
headers,
|
|
credentials: 'same-origin',
|
|
signal: controller.signal,
|
|
openWhenHidden: true,
|
|
onopen: async (response) => {
|
|
if (response.ok) {
|
|
updateState({ status: 'running', error: '' });
|
|
syncCurrentSessionToRecentSessions();
|
|
return;
|
|
}
|
|
|
|
if ([404, 410].includes(response.status)) {
|
|
throw new ConsoleSessionExpiredError(await parseResponseError(response));
|
|
}
|
|
|
|
throw new Error(await parseResponseError(response));
|
|
},
|
|
onmessage: (event: EventSourceMessage) => {
|
|
if (event.id && shouldSkipEvent(event.id)) {
|
|
return;
|
|
}
|
|
|
|
let payload: Record<string, unknown> | null = null;
|
|
if (event.data) {
|
|
try {
|
|
payload = JSON.parse(event.data) as Record<string, unknown>;
|
|
} catch {
|
|
payload = null;
|
|
}
|
|
}
|
|
|
|
if (event.id) {
|
|
updateState({ lastEventId: event.id });
|
|
}
|
|
|
|
if (event.event === 'expired' || payload?.type === 'expired' || payload?.expired === true) {
|
|
markExpired();
|
|
return;
|
|
}
|
|
|
|
if (event.event === 'status') {
|
|
updateState(normalizeResponse(payload as ConsoleSessionResponse));
|
|
syncCurrentSessionToRecentSessions();
|
|
return;
|
|
}
|
|
|
|
if (event.event === 'output') {
|
|
const line = typeof payload?.line === 'string' ? payload.line : event.data;
|
|
appendTranscript(`${line || ''}\n`);
|
|
return;
|
|
}
|
|
|
|
if (event.event === 'close') {
|
|
const nextExitCode =
|
|
typeof payload?.exitcode === 'number'
|
|
? payload.exitcode
|
|
: typeof payload?.exit_code === 'number'
|
|
? payload.exit_code
|
|
: sessionState.value.exitCode;
|
|
const nextStatus = normalizeStatus(
|
|
typeof payload?.status === 'string' ? payload.status : null,
|
|
'finished',
|
|
);
|
|
|
|
if (payload?.expired === true || payload?.status === 'expired') {
|
|
markExpired();
|
|
return;
|
|
}
|
|
|
|
finishSession(nextStatus === 'error' ? 'error' : nextStatus, nextExitCode);
|
|
finalMetadataRefresh = refreshSessionMetadata();
|
|
}
|
|
},
|
|
onclose: () => {
|
|
if (controller.signal.aborted || nonce !== connectionNonce.value) {
|
|
return;
|
|
}
|
|
|
|
if (!sessionState.value.sessionId || !isActiveSessionStatus(sessionState.value.status)) {
|
|
return;
|
|
}
|
|
|
|
scheduleReconnect();
|
|
},
|
|
onerror: (error) => {
|
|
throw error instanceof Error ? error : new Error(String(error));
|
|
},
|
|
});
|
|
|
|
if (finalMetadataRefresh) {
|
|
await finalMetadataRefresh;
|
|
}
|
|
} catch (error) {
|
|
if (controller.signal.aborted || nonce !== connectionNonce.value) {
|
|
return;
|
|
}
|
|
|
|
if (error instanceof ConsoleSessionExpiredError) {
|
|
markExpired();
|
|
return;
|
|
}
|
|
|
|
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
|
scheduleReconnect();
|
|
return;
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
appendTranscript(`Error: ${message}\n`);
|
|
updateState({ status: 'error', error: message });
|
|
syncCurrentSessionToRecentSessions();
|
|
} finally {
|
|
if (streamController.value === controller) {
|
|
streamController.value = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
const restoreSession = async (): Promise<void> => {
|
|
try {
|
|
if (sessionState.value.sessionId) {
|
|
const payload = await readSessionResponse(
|
|
`/api/system/terminal/${encodeURIComponent(sessionState.value.sessionId)}`,
|
|
);
|
|
|
|
if (payload) {
|
|
updateState(normalizeResponse(payload));
|
|
syncCurrentSessionToRecentSessions();
|
|
}
|
|
|
|
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
|
await connectStream();
|
|
}
|
|
|
|
await fetchRecentSessions();
|
|
|
|
return;
|
|
}
|
|
|
|
if (
|
|
sessionState.value.sessionId ||
|
|
sessionState.value.command ||
|
|
sessionState.value.transcript.length > 0
|
|
) {
|
|
await fetchRecentSessions();
|
|
return;
|
|
}
|
|
|
|
const active = await readSessionResponse('/api/system/terminal/active', { allowMissing: true });
|
|
if (!active) {
|
|
await fetchRecentSessions();
|
|
return;
|
|
}
|
|
|
|
updateState(normalizeResponse(active));
|
|
syncCurrentSessionToRecentSessions();
|
|
|
|
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
|
await connectStream();
|
|
}
|
|
|
|
await fetchRecentSessions();
|
|
} catch (error) {
|
|
if (error instanceof ConsoleSessionExpiredError) {
|
|
markExpired();
|
|
await fetchRecentSessions();
|
|
return;
|
|
}
|
|
|
|
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
|
await connectStream();
|
|
}
|
|
|
|
await fetchRecentSessions();
|
|
}
|
|
};
|
|
|
|
const startSession = async ({
|
|
command,
|
|
displayCommand = command,
|
|
}: StartConsoleSessionInput): Promise<boolean> => {
|
|
stopStream();
|
|
updateState({
|
|
sessionId: null,
|
|
command: displayCommand,
|
|
status: 'starting',
|
|
lastEventId: null,
|
|
exitCode: null,
|
|
error: '',
|
|
});
|
|
|
|
try {
|
|
const response = await request('/api/system/terminal', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ command }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await parseResponseError(response));
|
|
}
|
|
|
|
const payload = await parse_api_response<ConsoleSessionResponse>(response.json());
|
|
updateState({
|
|
...normalizeResponse(payload),
|
|
command: displayCommand,
|
|
status: normalizeStatus(payload.status, 'running'),
|
|
lastEventId: null,
|
|
exitCode: null,
|
|
transcript: trimTranscript([
|
|
...sessionState.value.transcript,
|
|
'user@YTPTube ~\n',
|
|
`$ yt-dlp ${displayCommand}\n`,
|
|
]),
|
|
error: '',
|
|
});
|
|
syncCurrentSessionToRecentSessions();
|
|
|
|
if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) {
|
|
await connectStream();
|
|
}
|
|
|
|
await fetchRecentSessions();
|
|
|
|
return Boolean(sessionState.value.sessionId);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
appendTranscript(`Error: ${message}\n`);
|
|
updateState({ status: 'error', error: message });
|
|
syncCurrentSessionToRecentSessions();
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const cancelSession = async (): Promise<CancelConsoleSessionResult> => {
|
|
if (!sessionState.value.sessionId) {
|
|
return { status: 'missing', message: 'No active terminal session found.' };
|
|
}
|
|
|
|
try {
|
|
const response = await request(
|
|
`/api/system/terminal/${encodeURIComponent(sessionState.value.sessionId)}`,
|
|
{
|
|
method: 'DELETE',
|
|
},
|
|
);
|
|
|
|
if (response.status === 404) {
|
|
await refreshSessionMetadata();
|
|
await fetchRecentSessions();
|
|
return { status: 'missing', message: 'Terminal session not found.' };
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await parseResponseError(response));
|
|
}
|
|
|
|
updateState({ error: '' });
|
|
await fetchRecentSessions();
|
|
return { status: 'cancelled' };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
updateState({ error: message });
|
|
return { status: 'error', message };
|
|
}
|
|
};
|
|
|
|
const resetState = (): void => {
|
|
stopStream();
|
|
sessionState.value = { ...DEFAULT_STATE };
|
|
recentSessions.value = [];
|
|
hiddenRecentSessions.value = [];
|
|
};
|
|
|
|
const replaySession = async (item: RecentSessionItem): Promise<boolean> => {
|
|
stopStream();
|
|
updateState({
|
|
sessionId: item.sessionId,
|
|
command: item.command,
|
|
status: item.status === 'running' ? 'reconnecting' : 'finished',
|
|
lastEventId: null,
|
|
exitCode: item.exitCode,
|
|
transcript: [],
|
|
error: '',
|
|
});
|
|
syncCurrentSessionToRecentSessions();
|
|
|
|
await connectStream();
|
|
await fetchRecentSessions();
|
|
|
|
if (sessionState.value.status === 'expired') {
|
|
hideRecentSession(item.sessionId);
|
|
return false;
|
|
}
|
|
|
|
return sessionState.value.status !== 'error';
|
|
};
|
|
|
|
const useConsoleSession = () => {
|
|
return {
|
|
state: sessionState,
|
|
bufferedTranscript: computed(() => sessionState.value.transcript.slice()),
|
|
isLoading: computed(() => isActiveSessionStatus(sessionState.value.status)),
|
|
recentSessions: computed(() => recentSessions.value.slice()),
|
|
clearTranscript,
|
|
clearRecentSessions,
|
|
fetchRecentSessions,
|
|
hideRecentSession,
|
|
replaySession,
|
|
restoreSession,
|
|
startSession,
|
|
cancelSession,
|
|
disconnect: stopStream,
|
|
__resetForTesting: resetState,
|
|
};
|
|
};
|
|
|
|
export { useConsoleSession };
|