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:
@@ -9,6 +9,11 @@ import {
|
|||||||
clearProgress,
|
clearProgress,
|
||||||
setProgress,
|
setProgress,
|
||||||
} from "@/modules/parcel-sync/services/progress-store";
|
} from "@/modules/parcel-sync/services/progress-store";
|
||||||
|
import {
|
||||||
|
getSessionCredentials,
|
||||||
|
registerJob,
|
||||||
|
unregisterJob,
|
||||||
|
} from "@/modules/parcel-sync/services/session-store";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -22,8 +27,14 @@ type ExportBundleRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validate = (body: ExportBundleRequest) => {
|
const validate = (body: ExportBundleRequest) => {
|
||||||
const username = String(body.username ?? "").trim();
|
// Priority: request body > session store > env vars
|
||||||
const password = String(body.password ?? "").trim();
|
const session = getSessionCredentials();
|
||||||
|
const username = String(
|
||||||
|
body.username || session?.username || process.env.ETERRA_USERNAME || "",
|
||||||
|
).trim();
|
||||||
|
const password = String(
|
||||||
|
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
|
||||||
|
).trim();
|
||||||
const siruta = String(body.siruta ?? "").trim();
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
|
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
|
||||||
const mode = body.mode === "magic" ? "magic" : "base";
|
const mode = body.mode === "magic" ? "magic" : "base";
|
||||||
@@ -158,6 +169,7 @@ export async function POST(req: Request) {
|
|||||||
const body = (await req.json()) as ExportBundleRequest;
|
const body = (await req.json()) as ExportBundleRequest;
|
||||||
const validated = validate(body);
|
const validated = validate(body);
|
||||||
jobId = validated.jobId;
|
jobId = validated.jobId;
|
||||||
|
if (jobId) registerJob(jobId);
|
||||||
pushProgress();
|
pushProgress();
|
||||||
|
|
||||||
const terenuriLayer = findLayerById("TERENURI_ACTIVE");
|
const terenuriLayer = findLayerById("TERENURI_ACTIVE");
|
||||||
@@ -816,6 +828,7 @@ export async function POST(req: Request) {
|
|||||||
validated.mode === "magic"
|
validated.mode === "magic"
|
||||||
? `eterra_uat_${validated.siruta}_magic.zip`
|
? `eterra_uat_${validated.siruta}_magic.zip`
|
||||||
: `eterra_uat_${validated.siruta}_terenuri_cladiri.zip`;
|
: `eterra_uat_${validated.siruta}_terenuri_cladiri.zip`;
|
||||||
|
if (jobId) unregisterJob(jobId);
|
||||||
return new Response(new Uint8Array(zipBuffer), {
|
return new Response(new Uint8Array(zipBuffer), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
@@ -830,6 +843,7 @@ export async function POST(req: Request) {
|
|||||||
note = undefined;
|
note = undefined;
|
||||||
pushProgress();
|
pushProgress();
|
||||||
scheduleClear(jobId);
|
scheduleClear(jobId);
|
||||||
|
if (jobId) unregisterJob(jobId);
|
||||||
const lower = errMessage.toLowerCase();
|
const lower = errMessage.toLowerCase();
|
||||||
const statusCode =
|
const statusCode =
|
||||||
lower.includes("login failed") || lower.includes("session") ? 401 : 400;
|
lower.includes("login failed") || lower.includes("session") ? 401 : 400;
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
clearProgress,
|
clearProgress,
|
||||||
setProgress,
|
setProgress,
|
||||||
} from "@/modules/parcel-sync/services/progress-store";
|
} from "@/modules/parcel-sync/services/progress-store";
|
||||||
|
import {
|
||||||
|
getSessionCredentials,
|
||||||
|
registerJob,
|
||||||
|
unregisterJob,
|
||||||
|
} from "@/modules/parcel-sync/services/session-store";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -21,8 +26,14 @@ type ExportLayerRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validate = (body: ExportLayerRequest) => {
|
const validate = (body: ExportLayerRequest) => {
|
||||||
const username = String(body.username ?? "").trim();
|
// Priority: request body > session store > env vars
|
||||||
const password = String(body.password ?? "").trim();
|
const session = getSessionCredentials();
|
||||||
|
const username = String(
|
||||||
|
body.username || session?.username || process.env.ETERRA_USERNAME || "",
|
||||||
|
).trim();
|
||||||
|
const password = String(
|
||||||
|
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
|
||||||
|
).trim();
|
||||||
const siruta = String(body.siruta ?? "").trim();
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
const layerId = String(body.layerId ?? "").trim();
|
const layerId = String(body.layerId ?? "").trim();
|
||||||
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
|
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
|
||||||
@@ -122,6 +133,7 @@ export async function POST(req: Request) {
|
|||||||
const body = (await req.json()) as ExportLayerRequest;
|
const body = (await req.json()) as ExportLayerRequest;
|
||||||
const validated = validate(body);
|
const validated = validate(body);
|
||||||
jobId = validated.jobId;
|
jobId = validated.jobId;
|
||||||
|
if (jobId) registerJob(jobId);
|
||||||
pushProgress();
|
pushProgress();
|
||||||
|
|
||||||
const layer = findLayerById(validated.layerId);
|
const layer = findLayerById(validated.layerId);
|
||||||
@@ -245,6 +257,7 @@ export async function POST(req: Request) {
|
|||||||
pushProgress();
|
pushProgress();
|
||||||
scheduleClear(jobId);
|
scheduleClear(jobId);
|
||||||
|
|
||||||
|
if (jobId) unregisterJob(jobId);
|
||||||
const filename = `eterra_uat_${validated.siruta}_${layer.name}.gpkg`;
|
const filename = `eterra_uat_${validated.siruta}_${layer.name}.gpkg`;
|
||||||
return new Response(new Uint8Array(gpkg), {
|
return new Response(new Uint8Array(gpkg), {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -260,6 +273,7 @@ export async function POST(req: Request) {
|
|||||||
note = undefined;
|
note = undefined;
|
||||||
pushProgress();
|
pushProgress();
|
||||||
scheduleClear(jobId);
|
scheduleClear(jobId);
|
||||||
|
if (jobId) unregisterJob(jobId);
|
||||||
const lower = errMessage.toLowerCase();
|
const lower = errMessage.toLowerCase();
|
||||||
const statusCode =
|
const statusCode =
|
||||||
lower.includes("login failed") || lower.includes("session") ? 401 : 400;
|
lower.includes("login failed") || lower.includes("session") ? 401 : 400;
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
import { createSession } from "@/modules/parcel-sync/services/session-store";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/eterra/login
|
||||||
|
* Legacy endpoint — kept for backward compat. Prefer /api/eterra/session.
|
||||||
|
*/
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as { username?: string; password?: string };
|
const body = (await req.json()) as { username?: string; password?: string };
|
||||||
@@ -24,6 +29,7 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await EterraClient.create(username, password);
|
await EterraClient.create(username, password);
|
||||||
|
createSession(username, password);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Eroare server";
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
destroySession,
|
||||||
|
forceDestroySession,
|
||||||
|
getSessionCredentials,
|
||||||
|
getSessionStatus,
|
||||||
|
} from "@/modules/parcel-sync/services/session-store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/eterra/session — returns current server-side session status.
|
||||||
|
* Any client can call this to check if eTerra is connected.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(getSessionStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/eterra/session — connect or disconnect.
|
||||||
|
*
|
||||||
|
* Connect: { action: "connect", username?, password? }
|
||||||
|
* Disconnect: { action: "disconnect", force?: boolean }
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as {
|
||||||
|
action?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = body.action ?? "connect";
|
||||||
|
|
||||||
|
if (action === "disconnect") {
|
||||||
|
if (body.force) {
|
||||||
|
forceDestroySession();
|
||||||
|
return NextResponse.json({ success: true, disconnected: true });
|
||||||
|
}
|
||||||
|
const result = destroySession();
|
||||||
|
if (!result.destroyed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: result.reason },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: true, disconnected: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
const username = (
|
||||||
|
body.username ??
|
||||||
|
process.env.ETERRA_USERNAME ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
const password = (
|
||||||
|
body.password ??
|
||||||
|
process.env.ETERRA_PASSWORD ??
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Credențiale eTerra lipsă" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already connected with same credentials
|
||||||
|
const existing = getSessionCredentials();
|
||||||
|
if (existing && existing.username === username) {
|
||||||
|
// Already connected — verify session is still alive by pinging
|
||||||
|
try {
|
||||||
|
await EterraClient.create(username, password);
|
||||||
|
return NextResponse.json({ success: true, alreadyConnected: true });
|
||||||
|
} catch {
|
||||||
|
// Session expired, re-login below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt login
|
||||||
|
await EterraClient.create(username, password);
|
||||||
|
createSession(username, password);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
const status = message.toLowerCase().includes("login") ? 401 : 500;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,10 +24,7 @@ import { Button } from "@/shared/components/ui/button";
|
|||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import {
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
} from "@/shared/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -56,6 +53,14 @@ import type { ParcelFeature } from "../types";
|
|||||||
|
|
||||||
type UatEntry = { siruta: string; name: string; county?: string };
|
type UatEntry = { siruta: string; name: string; county?: string };
|
||||||
|
|
||||||
|
type SessionStatus = {
|
||||||
|
connected: boolean;
|
||||||
|
username?: string;
|
||||||
|
connectedAt?: string;
|
||||||
|
activeJobCount: number;
|
||||||
|
activeJobPhase?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ExportProgress = {
|
type ExportProgress = {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
downloaded: number;
|
downloaded: number;
|
||||||
@@ -100,30 +105,20 @@ function formatArea(val?: number | null) {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function ConnectionPill({
|
function ConnectionPill({
|
||||||
connected,
|
session,
|
||||||
connecting,
|
connecting,
|
||||||
connectionError,
|
connectionError,
|
||||||
connectedAt,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
onUsernameChange,
|
|
||||||
onPasswordChange,
|
|
||||||
onConnect,
|
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
}: {
|
}: {
|
||||||
connected: boolean;
|
session: SessionStatus;
|
||||||
connecting: boolean;
|
connecting: boolean;
|
||||||
connectionError: string;
|
connectionError: string;
|
||||||
connectedAt: Date | null;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
onUsernameChange: (v: string) => void;
|
|
||||||
onPasswordChange: (v: string) => void;
|
|
||||||
onConnect: () => void;
|
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
}) {
|
}) {
|
||||||
const elapsed = connectedAt
|
const elapsed = session.connectedAt
|
||||||
? Math.floor((Date.now() - connectedAt.getTime()) / 60_000)
|
? Math.floor(
|
||||||
|
(Date.now() - new Date(session.connectedAt).getTime()) / 60_000,
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const elapsedLabel =
|
const elapsedLabel =
|
||||||
elapsed < 1
|
elapsed < 1
|
||||||
@@ -140,7 +135,7 @@ function ConnectionPill({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-all",
|
"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",
|
"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"
|
? "border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400"
|
||||||
: connectionError
|
: connectionError
|
||||||
? "border-rose-200 bg-rose-50/80 text-rose-600 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400"
|
? "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 ? (
|
{connecting ? (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
) : connected ? (
|
) : session.connected ? (
|
||||||
<span className="relative flex h-2 w-2">
|
<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="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" />
|
<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">
|
<span className="hidden sm:inline">
|
||||||
{connecting
|
{connecting
|
||||||
? "Se conectează…"
|
? "Se conectează…"
|
||||||
: connected
|
: session.connected
|
||||||
? "eTerra"
|
? "eTerra"
|
||||||
: connectionError
|
: connectionError
|
||||||
? "Eroare"
|
? "Eroare"
|
||||||
@@ -176,7 +171,7 @@ function ConnectionPill({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2.5 border-b",
|
"px-3 py-2.5 border-b",
|
||||||
connected
|
session.connected
|
||||||
? "bg-emerald-50/50 dark:bg-emerald-950/20"
|
? "bg-emerald-50/50 dark:bg-emerald-950/20"
|
||||||
: "bg-muted/30",
|
: "bg-muted/30",
|
||||||
)}
|
)}
|
||||||
@@ -185,72 +180,64 @@ function ConnectionPill({
|
|||||||
<DropdownMenuLabel className="p-0 text-xs font-semibold">
|
<DropdownMenuLabel className="p-0 text-xs font-semibold">
|
||||||
Conexiune eTerra
|
Conexiune eTerra
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{connected && (
|
{session.connected && (
|
||||||
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-mono">
|
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-mono">
|
||||||
{elapsedLabel}
|
{elapsedLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{connected && username && (
|
{session.connected && session.username && (
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||||
{username}
|
{session.username}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{connectionError && (
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Credentials form */}
|
{/* Info when not connected */}
|
||||||
{!connected && (
|
{!session.connected && !connectionError && (
|
||||||
<div className="p-3 space-y-2.5">
|
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||||
<div className="space-y-1">
|
<p>Conexiunea se face automat când începi să scrii un UAT.</p>
|
||||||
<Label htmlFor="pill-user" className="text-[11px]">
|
<p className="mt-1 text-[11px] opacity-70">
|
||||||
Email eTerra
|
Credențialele sunt preluate din configurarea serverului.
|
||||||
</Label>
|
</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connected actions */}
|
{/* Error detail */}
|
||||||
{connected && (
|
{!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" />
|
<DropdownMenuSeparator className="m-0" />
|
||||||
<div className="p-1.5">
|
<div className="p-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -274,13 +261,15 @@ function ConnectionPill({
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function ParcelSyncModule() {
|
export function ParcelSyncModule() {
|
||||||
/* ── Connection ─────────────────────────────────────────────── */
|
/* ── Server session ─────────────────────────────────────────── */
|
||||||
const [connected, setConnected] = useState(false);
|
const [session, setSession] = useState<SessionStatus>({
|
||||||
|
connected: false,
|
||||||
|
activeJobCount: 0,
|
||||||
|
});
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [connectionError, setConnectionError] = useState("");
|
const [connectionError, setConnectionError] = useState("");
|
||||||
const [connectedAt, setConnectedAt] = useState<Date | null>(null);
|
const autoConnectAttempted = useRef(false);
|
||||||
const [username, setUsername] = useState("");
|
const sessionPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
|
|
||||||
/* ── UAT autocomplete ───────────────────────────────────────── */
|
/* ── UAT autocomplete ───────────────────────────────────────── */
|
||||||
const [uatData, setUatData] = useState<UatEntry[]>([]);
|
const [uatData, setUatData] = useState<UatEntry[]>([]);
|
||||||
@@ -315,15 +304,36 @@ export function ParcelSyncModule() {
|
|||||||
const PAGE_SIZE = 50;
|
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(() => {
|
useEffect(() => {
|
||||||
fetch("/uat.json")
|
fetch("/uat.json")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data: UatEntry[]) => setUatData(data))
|
.then((data: UatEntry[]) => setUatData(data))
|
||||||
.catch(() => {});
|
.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 */
|
/* UAT autocomplete filter */
|
||||||
@@ -348,28 +358,26 @@ export function ParcelSyncModule() {
|
|||||||
}, [uatQuery, uatData]);
|
}, [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);
|
setConnecting(true);
|
||||||
setConnectionError("");
|
setConnectionError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/eterra/login", {
|
const res = await fetch("/api/eterra/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ action: "connect" }),
|
||||||
username: username || undefined,
|
|
||||||
password: password || undefined,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setConnected(true);
|
await fetchSession();
|
||||||
setConnectedAt(new Date());
|
|
||||||
} else {
|
} else {
|
||||||
setConnectionError(data.error ?? "Eroare conectare");
|
setConnectionError(data.error ?? "Eroare conectare");
|
||||||
}
|
}
|
||||||
@@ -377,12 +385,33 @@ export function ParcelSyncModule() {
|
|||||||
setConnectionError("Eroare rețea");
|
setConnectionError("Eroare rețea");
|
||||||
}
|
}
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
}, [username, password]);
|
}, [session.connected, connecting, fetchSession]);
|
||||||
|
|
||||||
const handleDisconnect = useCallback(() => {
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
setConnected(false);
|
/* Disconnect */
|
||||||
setConnectedAt(null);
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
setConnectionError("");
|
|
||||||
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: username || undefined,
|
|
||||||
password: password || undefined,
|
|
||||||
siruta,
|
siruta,
|
||||||
jobId,
|
jobId,
|
||||||
mode,
|
mode,
|
||||||
@@ -494,7 +521,7 @@ export function ParcelSyncModule() {
|
|||||||
}
|
}
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
},
|
},
|
||||||
[siruta, exporting, username, password, startPolling],
|
[siruta, exporting, startPolling],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
@@ -517,8 +544,6 @@ export function ParcelSyncModule() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: username || undefined,
|
|
||||||
password: password || undefined,
|
|
||||||
siruta,
|
siruta,
|
||||||
layerId,
|
layerId,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -565,7 +590,7 @@ export function ParcelSyncModule() {
|
|||||||
}
|
}
|
||||||
setDownloadingLayer(null);
|
setDownloadingLayer(null);
|
||||||
},
|
},
|
||||||
[siruta, downloadingLayer, username, password, startPolling],
|
[siruta, downloadingLayer, startPolling],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
@@ -645,6 +670,10 @@ export function ParcelSyncModule() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setUatQuery(e.target.value);
|
setUatQuery(e.target.value);
|
||||||
setShowUatResults(true);
|
setShowUatResults(true);
|
||||||
|
// Auto-connect on first keystroke
|
||||||
|
if (e.target.value.trim().length >= 1) {
|
||||||
|
void triggerAutoConnect();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => setShowUatResults(true)}
|
onFocus={() => setShowUatResults(true)}
|
||||||
onBlur={() => setTimeout(() => setShowUatResults(false), 150)}
|
onBlur={() => setTimeout(() => setShowUatResults(false), 150)}
|
||||||
@@ -697,15 +726,9 @@ export function ParcelSyncModule() {
|
|||||||
|
|
||||||
{/* Connection pill */}
|
{/* Connection pill */}
|
||||||
<ConnectionPill
|
<ConnectionPill
|
||||||
connected={connected}
|
session={session}
|
||||||
connecting={connecting}
|
connecting={connecting}
|
||||||
connectionError={connectionError}
|
connectionError={connectionError}
|
||||||
connectedAt={connectedAt}
|
|
||||||
username={username}
|
|
||||||
password={password}
|
|
||||||
onUsernameChange={setUsername}
|
|
||||||
onPasswordChange={setPassword}
|
|
||||||
onConnect={handleConnect}
|
|
||||||
onDisconnect={handleDisconnect}
|
onDisconnect={handleDisconnect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -912,12 +935,12 @@ export function ParcelSyncModule() {
|
|||||||
{/* Tab 2: Layer catalog */}
|
{/* Tab 2: Layer catalog */}
|
||||||
{/* ═══════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════ */}
|
||||||
<TabsContent value="layers" className="space-y-4">
|
<TabsContent value="layers" className="space-y-4">
|
||||||
{!sirutaValid || !connected ? (
|
{!sirutaValid || !session.connected ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
<Layers className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
<Layers className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
<p>
|
<p>
|
||||||
{!connected
|
{!session.connected
|
||||||
? "Conectează-te la eTerra și selectează un UAT."
|
? "Conectează-te la eTerra și selectează un UAT."
|
||||||
: "Selectează un UAT pentru a vedea catalogul de layere."}
|
: "Selectează un UAT pentru a vedea catalogul de layere."}
|
||||||
</p>
|
</p>
|
||||||
@@ -1045,7 +1068,7 @@ export function ParcelSyncModule() {
|
|||||||
{/* ═══════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════ */}
|
||||||
<TabsContent value="export" className="space-y-4">
|
<TabsContent value="export" className="space-y-4">
|
||||||
{/* Hero buttons */}
|
{/* Hero buttons */}
|
||||||
{sirutaValid && connected ? (
|
{sirutaValid && session.connected ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -1090,7 +1113,7 @@ export function ParcelSyncModule() {
|
|||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
{!connected ? (
|
{!session.connected ? (
|
||||||
<>
|
<>
|
||||||
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
<p>Conectează-te la eTerra pentru a activa exportul.</p>
|
<p>Conectează-te la eTerra pentru a activa exportul.</p>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user