feat(parcel-sync): server-side eTerra session + auto-connect on UAT typing

- Add session-store.ts: global singleton for shared eTerra session state
  with job tracking (registerJob/unregisterJob/getRunningJobs)
- Add GET/POST /api/eterra/session: connect/disconnect with job-running guard
- Export routes: credential fallback chain (body > session > env vars),
  register/unregister active jobs for disconnect protection
- Login route: also creates server-side session
- ConnectionPill: session-aware display with job count, no credential form
- Auto-connect: triggers on first UAT keystroke via autoConnectAttempted ref
- Session polling: all clients poll GET /api/eterra/session every 30s
- Multi-client: any browser sees shared connection state
This commit is contained in:
AI Assistant
2026-03-06 19:06:39 +02:00
parent 129b62758c
commit bd90c4e30f
6 changed files with 416 additions and 116 deletions
@@ -0,0 +1,148 @@
/**
* Server-side eTerra session store (global singleton).
*
* Holds one shared session for the whole app. Multiple browser clients
* see the same connection state. The EterraClient already caches sessions
* by credential hash, so we just store which credentials are active +
* metadata (who connected, when, running jobs).
*/
import { getProgress } from "./progress-store";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type EterraSession = {
/** Username used to connect */
username: string;
/** Password (kept in memory only, never sent to clients) */
password: string;
/** ISO timestamp of connection */
connectedAt: string;
/** Running job IDs — tracked so we can warn before disconnect */
activeJobs: Set<string>;
};
export type SessionStatus = {
connected: boolean;
username?: string;
connectedAt?: string;
/** How many jobs are currently running */
activeJobCount: number;
/** First running job's phase (for UI hint) */
activeJobPhase?: string;
};
/* ------------------------------------------------------------------ */
/* Global store */
/* ------------------------------------------------------------------ */
const g = globalThis as { __eterraSessionStore?: EterraSession | null };
function getSession(): EterraSession | null {
return g.__eterraSessionStore ?? null;
}
function setSession(session: EterraSession | null) {
g.__eterraSessionStore = session;
}
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
export function createSession(username: string, password: string): void {
setSession({
username,
password,
connectedAt: new Date().toISOString(),
activeJobs: new Set(),
});
}
export function destroySession(): { destroyed: boolean; reason?: string } {
const session = getSession();
if (!session) return { destroyed: true };
// Check for running jobs
const running = getRunningJobs(session);
if (running.length > 0) {
return {
destroyed: false,
reason: `Există ${running.length} job(uri) active. Așteaptă finalizarea lor înainte de deconectare.`,
};
}
setSession(null);
return { destroyed: true };
}
export function forceDestroySession(): void {
setSession(null);
}
export function getSessionCredentials(): {
username: string;
password: string;
} | null {
const session = getSession();
if (!session) return null;
return { username: session.username, password: session.password };
}
export function registerJob(jobId: string): void {
const session = getSession();
if (session) session.activeJobs.add(jobId);
}
export function unregisterJob(jobId: string): void {
const session = getSession();
if (session) session.activeJobs.delete(jobId);
}
export function getSessionStatus(): SessionStatus {
const session = getSession();
if (!session) {
return { connected: false, activeJobCount: 0 };
}
const running = getRunningJobs(session);
// Find one running job's phase for a UI hint
let activeJobPhase: string | undefined;
for (const jid of running) {
const p = getProgress(jid);
if (p?.phase) {
activeJobPhase = p.phase;
break;
}
}
return {
connected: true,
username: session.username,
connectedAt: session.connectedAt,
activeJobCount: running.length,
activeJobPhase,
};
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getRunningJobs(session: EterraSession): string[] {
const running: string[] = [];
for (const jid of session.activeJobs) {
const p = getProgress(jid);
// If progress exists and is still running, count it
if (p && p.status === "running") {
running.push(jid);
} else {
// Clean up finished/unknown jobs
session.activeJobs.delete(jid);
}
}
return running;
}