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:
@@ -24,10 +24,7 @@ import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -56,6 +53,14 @@ import type { ParcelFeature } from "../types";
|
||||
|
||||
type UatEntry = { siruta: string; name: string; county?: string };
|
||||
|
||||
type SessionStatus = {
|
||||
connected: boolean;
|
||||
username?: string;
|
||||
connectedAt?: string;
|
||||
activeJobCount: number;
|
||||
activeJobPhase?: string;
|
||||
};
|
||||
|
||||
type ExportProgress = {
|
||||
jobId: string;
|
||||
downloaded: number;
|
||||
@@ -100,30 +105,20 @@ function formatArea(val?: number | null) {
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ConnectionPill({
|
||||
connected,
|
||||
session,
|
||||
connecting,
|
||||
connectionError,
|
||||
connectedAt,
|
||||
username,
|
||||
password,
|
||||
onUsernameChange,
|
||||
onPasswordChange,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}: {
|
||||
connected: boolean;
|
||||
session: SessionStatus;
|
||||
connecting: boolean;
|
||||
connectionError: string;
|
||||
connectedAt: Date | null;
|
||||
username: string;
|
||||
password: string;
|
||||
onUsernameChange: (v: string) => void;
|
||||
onPasswordChange: (v: string) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
}) {
|
||||
const elapsed = connectedAt
|
||||
? Math.floor((Date.now() - connectedAt.getTime()) / 60_000)
|
||||
const elapsed = session.connectedAt
|
||||
? Math.floor(
|
||||
(Date.now() - new Date(session.connectedAt).getTime()) / 60_000,
|
||||
)
|
||||
: 0;
|
||||
const elapsedLabel =
|
||||
elapsed < 1
|
||||
@@ -140,7 +135,7 @@ function ConnectionPill({
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
"hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
connected
|
||||
session.connected
|
||||
? "border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400"
|
||||
: connectionError
|
||||
? "border-rose-200 bg-rose-50/80 text-rose-600 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400"
|
||||
@@ -149,7 +144,7 @@ function ConnectionPill({
|
||||
>
|
||||
{connecting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : connected ? (
|
||||
) : session.connected ? (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||
@@ -162,7 +157,7 @@ function ConnectionPill({
|
||||
<span className="hidden sm:inline">
|
||||
{connecting
|
||||
? "Se conectează…"
|
||||
: connected
|
||||
: session.connected
|
||||
? "eTerra"
|
||||
: connectionError
|
||||
? "Eroare"
|
||||
@@ -176,7 +171,7 @@ function ConnectionPill({
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2.5 border-b",
|
||||
connected
|
||||
session.connected
|
||||
? "bg-emerald-50/50 dark:bg-emerald-950/20"
|
||||
: "bg-muted/30",
|
||||
)}
|
||||
@@ -185,72 +180,64 @@ function ConnectionPill({
|
||||
<DropdownMenuLabel className="p-0 text-xs font-semibold">
|
||||
Conexiune eTerra
|
||||
</DropdownMenuLabel>
|
||||
{connected && (
|
||||
{session.connected && (
|
||||
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-mono">
|
||||
{elapsedLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{connected && username && (
|
||||
{session.connected && session.username && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||
{username}
|
||||
{session.username}
|
||||
</p>
|
||||
)}
|
||||
{connectionError && (
|
||||
<p className="text-[11px] text-rose-500 mt-0.5">{connectionError}</p>
|
||||
<p className="text-[11px] text-rose-500 mt-0.5">
|
||||
{connectionError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Credentials form */}
|
||||
{!connected && (
|
||||
<div className="p-3 space-y-2.5">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="pill-user" className="text-[11px]">
|
||||
Email eTerra
|
||||
</Label>
|
||||
<Input
|
||||
id="pill-user"
|
||||
placeholder="din variabile de mediu"
|
||||
value={username}
|
||||
onChange={(e) => onUsernameChange(e.target.value)}
|
||||
autoComplete="username"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="pill-pass" className="text-[11px]">
|
||||
Parolă
|
||||
</Label>
|
||||
<Input
|
||||
id="pill-pass"
|
||||
type="password"
|
||||
placeholder="din variabile de mediu"
|
||||
value={password}
|
||||
onChange={(e) => onPasswordChange(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full h-8 text-xs"
|
||||
disabled={connecting}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onConnect();
|
||||
}}
|
||||
>
|
||||
{connecting && (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
Conectare
|
||||
</Button>
|
||||
{/* Info when not connected */}
|
||||
{!session.connected && !connectionError && (
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||
<p>Conexiunea se face automat când începi să scrii un UAT.</p>
|
||||
<p className="mt-1 text-[11px] opacity-70">
|
||||
Credențialele sunt preluate din configurarea serverului.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected actions */}
|
||||
{connected && (
|
||||
{/* Error detail */}
|
||||
{!session.connected && connectionError && (
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
Conexiunea automată a eșuat. Verifică credențialele din
|
||||
variabilele de mediu (ETERRA_USERNAME / ETERRA_PASSWORD).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected — active jobs info + disconnect */}
|
||||
{session.connected && (
|
||||
<>
|
||||
{session.activeJobCount > 0 && (
|
||||
<div className="px-3 py-2 border-b bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<p className="text-[11px] text-amber-700 dark:text-amber-400">
|
||||
<span className="font-semibold">
|
||||
{session.activeJobCount} job
|
||||
{session.activeJobCount > 1 ? "-uri" : ""} activ
|
||||
{session.activeJobCount > 1 ? "e" : ""}
|
||||
</span>
|
||||
{session.activeJobPhase && (
|
||||
<span className="opacity-70">
|
||||
{" "}
|
||||
— {session.activeJobPhase}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuSeparator className="m-0" />
|
||||
<div className="p-1.5">
|
||||
<button
|
||||
@@ -274,13 +261,15 @@ function ConnectionPill({
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ParcelSyncModule() {
|
||||
/* ── Connection ─────────────────────────────────────────────── */
|
||||
const [connected, setConnected] = useState(false);
|
||||
/* ── Server session ─────────────────────────────────────────── */
|
||||
const [session, setSession] = useState<SessionStatus>({
|
||||
connected: false,
|
||||
activeJobCount: 0,
|
||||
});
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState("");
|
||||
const [connectedAt, setConnectedAt] = useState<Date | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const autoConnectAttempted = useRef(false);
|
||||
const sessionPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
/* ── UAT autocomplete ───────────────────────────────────────── */
|
||||
const [uatData, setUatData] = useState<UatEntry[]>([]);
|
||||
@@ -315,15 +304,36 @@ export function ParcelSyncModule() {
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
/* Load UAT data */
|
||||
/* Load UAT data + check server session on mount */
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
|
||||
const fetchSession = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/session");
|
||||
const data = (await res.json()) as SessionStatus;
|
||||
setSession(data);
|
||||
if (data.connected) setConnectionError("");
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/uat.json")
|
||||
.then((res) => res.json())
|
||||
.then((data: UatEntry[]) => setUatData(data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Check existing server session on mount
|
||||
void fetchSession();
|
||||
|
||||
// Poll session every 30s to stay in sync with other clients
|
||||
sessionPollRef.current = setInterval(() => void fetchSession(), 30_000);
|
||||
return () => {
|
||||
if (sessionPollRef.current) clearInterval(sessionPollRef.current);
|
||||
};
|
||||
}, [fetchSession]);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
/* UAT autocomplete filter */
|
||||
@@ -348,28 +358,26 @@ export function ParcelSyncModule() {
|
||||
}, [uatQuery, uatData]);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
/* Connection */
|
||||
/* Auto-connect: trigger on first UAT keystroke */
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
const triggerAutoConnect = useCallback(async () => {
|
||||
if (session.connected || connecting || autoConnectAttempted.current) return;
|
||||
autoConnectAttempted.current = true;
|
||||
setConnecting(true);
|
||||
setConnectionError("");
|
||||
try {
|
||||
const res = await fetch("/api/eterra/login", {
|
||||
const res = await fetch("/api/eterra/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
}),
|
||||
body: JSON.stringify({ action: "connect" }),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
if (data.success) {
|
||||
setConnected(true);
|
||||
setConnectedAt(new Date());
|
||||
await fetchSession();
|
||||
} else {
|
||||
setConnectionError(data.error ?? "Eroare conectare");
|
||||
}
|
||||
@@ -377,12 +385,33 @@ export function ParcelSyncModule() {
|
||||
setConnectionError("Eroare rețea");
|
||||
}
|
||||
setConnecting(false);
|
||||
}, [username, password]);
|
||||
}, [session.connected, connecting, fetchSession]);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
setConnected(false);
|
||||
setConnectedAt(null);
|
||||
setConnectionError("");
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
/* Disconnect */
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "disconnect" }),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
if (data.success) {
|
||||
setSession({ connected: false, activeJobCount: 0 });
|
||||
autoConnectAttempted.current = false;
|
||||
} else {
|
||||
// Jobs are running — show warning
|
||||
setConnectionError(data.error ?? "Nu se poate deconecta");
|
||||
}
|
||||
} catch {
|
||||
setConnectionError("Eroare rețea");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
@@ -442,8 +471,6 @@ export function ParcelSyncModule() {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
siruta,
|
||||
jobId,
|
||||
mode,
|
||||
@@ -494,7 +521,7 @@ export function ParcelSyncModule() {
|
||||
}
|
||||
setExporting(false);
|
||||
},
|
||||
[siruta, exporting, username, password, startPolling],
|
||||
[siruta, exporting, startPolling],
|
||||
);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
@@ -517,8 +544,6 @@ export function ParcelSyncModule() {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
siruta,
|
||||
layerId,
|
||||
jobId,
|
||||
@@ -565,7 +590,7 @@ export function ParcelSyncModule() {
|
||||
}
|
||||
setDownloadingLayer(null);
|
||||
},
|
||||
[siruta, downloadingLayer, username, password, startPolling],
|
||||
[siruta, downloadingLayer, startPolling],
|
||||
);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
@@ -645,6 +670,10 @@ export function ParcelSyncModule() {
|
||||
onChange={(e) => {
|
||||
setUatQuery(e.target.value);
|
||||
setShowUatResults(true);
|
||||
// Auto-connect on first keystroke
|
||||
if (e.target.value.trim().length >= 1) {
|
||||
void triggerAutoConnect();
|
||||
}
|
||||
}}
|
||||
onFocus={() => setShowUatResults(true)}
|
||||
onBlur={() => setTimeout(() => setShowUatResults(false), 150)}
|
||||
@@ -697,15 +726,9 @@ export function ParcelSyncModule() {
|
||||
|
||||
{/* Connection pill */}
|
||||
<ConnectionPill
|
||||
connected={connected}
|
||||
session={session}
|
||||
connecting={connecting}
|
||||
connectionError={connectionError}
|
||||
connectedAt={connectedAt}
|
||||
username={username}
|
||||
password={password}
|
||||
onUsernameChange={setUsername}
|
||||
onPasswordChange={setPassword}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
</div>
|
||||
@@ -912,12 +935,12 @@ export function ParcelSyncModule() {
|
||||
{/* Tab 2: Layer catalog */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<TabsContent value="layers" className="space-y-4">
|
||||
{!sirutaValid || !connected ? (
|
||||
{!sirutaValid || !session.connected ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Layers className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>
|
||||
{!connected
|
||||
{!session.connected
|
||||
? "Conectează-te la eTerra și selectează un UAT."
|
||||
: "Selectează un UAT pentru a vedea catalogul de layere."}
|
||||
</p>
|
||||
@@ -1045,7 +1068,7 @@ export function ParcelSyncModule() {
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<TabsContent value="export" className="space-y-4">
|
||||
{/* Hero buttons */}
|
||||
{sirutaValid && connected ? (
|
||||
{sirutaValid && session.connected ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -1090,7 +1113,7 @@ export function ParcelSyncModule() {
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
{!connected ? (
|
||||
{!session.connected ? (
|
||||
<>
|
||||
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Conectează-te la eTerra pentru a activa exportul.</p>
|
||||
|
||||
Reference in New Issue
Block a user