Add fallback to tunnel_status when metrics query fails in current_metrics function, returning zero bytes with actual tunnel state. Update waitForTunnelStatus in frontend to use tunnel_status instead of tunnel_metrics for status polling and refresh metrics separately on success. Change CloseRequested window event handler to call app_handle().exit(0) instead of no-op. Replace "sc" with "sc.exe" in all Windows service command
369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
|
|
type EnrollmentState = {
|
|
assignedIp: string;
|
|
resources: string[];
|
|
profileRevision: number;
|
|
gatewayEndpoint: string;
|
|
profilePath: string;
|
|
lastSyncTime: string;
|
|
tunnelStrategy: string;
|
|
};
|
|
|
|
type TunnelMetrics = {
|
|
active: boolean;
|
|
rxBytes: number;
|
|
txBytes: number;
|
|
};
|
|
|
|
function formatInvokeError(err: unknown, fallback: string) {
|
|
if (typeof err === "string" && err.trim().length > 0) {
|
|
return err;
|
|
}
|
|
|
|
if (err instanceof Error && err.message.trim().length > 0) {
|
|
return err.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function isAlreadyRunningError(err: unknown) {
|
|
const message = formatInvokeError(err, "");
|
|
return message.toLowerCase().includes("already installed and running");
|
|
}
|
|
|
|
function currentProfileLabel(state: EnrollmentState | null) {
|
|
if (!state) {
|
|
return "Not provisioned";
|
|
}
|
|
|
|
if (state.resources.includes("0.0.0.0/0")) {
|
|
return "Full tunnel";
|
|
}
|
|
|
|
if (state.resources.length === 1) {
|
|
return "Single resource access";
|
|
}
|
|
|
|
return `Split tunnel (${state.resources.length} resources)`;
|
|
}
|
|
|
|
function formatDataSize(bytes: number) {
|
|
if (!bytes) {
|
|
return "0 B";
|
|
}
|
|
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let value = bytes;
|
|
let unitIndex = 0;
|
|
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
|
|
return `${value >= 100 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
export function App() {
|
|
const [serverUrl, setServerUrl] = useState("http://localhost");
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [connected, setConnected] = useState(false);
|
|
const [state, setState] = useState<EnrollmentState | null>(null);
|
|
const [metrics, setMetrics] = useState<TunnelMetrics>({
|
|
active: false,
|
|
rxBytes: 0,
|
|
txBytes: 0
|
|
});
|
|
|
|
async function refreshTunnelMetrics() {
|
|
try {
|
|
const value = await invoke<TunnelMetrics>("tunnel_metrics");
|
|
setMetrics(value);
|
|
setConnected(value.active);
|
|
} catch {
|
|
try {
|
|
const active = await invoke<boolean>("tunnel_status");
|
|
setConnected(active);
|
|
setMetrics((current) => ({ ...current, active }));
|
|
} catch {
|
|
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
|
|
setConnected(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void invoke<EnrollmentState | null>("load_state")
|
|
.then(async (value) => {
|
|
if (value) {
|
|
setState(value);
|
|
await refreshTunnelMetrics();
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!state) {
|
|
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
|
|
return undefined;
|
|
}
|
|
|
|
void refreshTunnelMetrics();
|
|
const timer = window.setInterval(() => {
|
|
void refreshTunnelMetrics();
|
|
}, 5000);
|
|
|
|
return () => window.clearInterval(timer);
|
|
}, [state]);
|
|
|
|
const profileLabel = useMemo(() => currentProfileLabel(state), [state]);
|
|
|
|
async function waitForTunnelStatus(expected: boolean) {
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
try {
|
|
const active = await invoke<boolean>("tunnel_status");
|
|
setConnected(active);
|
|
if (active === expected) {
|
|
await refreshTunnelMetrics();
|
|
return active;
|
|
}
|
|
} catch {
|
|
if (!expected) {
|
|
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
|
|
setConnected(false);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
await new Promise((resolve) => window.setTimeout(resolve, 500));
|
|
}
|
|
|
|
return invoke<boolean>("tunnel_status")
|
|
.then(async (active) => {
|
|
setConnected(active);
|
|
await refreshTunnelMetrics();
|
|
return active;
|
|
})
|
|
.catch(() => false);
|
|
}
|
|
|
|
async function onSubmit(event: FormEvent) {
|
|
event.preventDefault();
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await invoke<EnrollmentState>("enroll_device", {
|
|
payload: { serverUrl, username, password }
|
|
});
|
|
setState(result);
|
|
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
|
|
} catch (err) {
|
|
setError(formatInvokeError(err, "Enrollment failed"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function onSyncProfile() {
|
|
setSyncing(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await invoke<EnrollmentState>("sync_profile");
|
|
setState(result);
|
|
await refreshTunnelMetrics();
|
|
} catch (err) {
|
|
setError(formatInvokeError(err, "Profile sync failed"));
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
}
|
|
|
|
async function toggleConnection() {
|
|
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
|
|
try {
|
|
if (!connected) {
|
|
const syncedState = await invoke<EnrollmentState>("connect_tunnel");
|
|
setState(syncedState);
|
|
} else {
|
|
await invoke(command);
|
|
}
|
|
const active = await waitForTunnelStatus(!connected);
|
|
setConnected(active);
|
|
if (!connected && !active) {
|
|
setError("Tunnel was installed, but no active interface could be verified yet.");
|
|
} else if (connected && active) {
|
|
setError("Tunnel could not be stopped cleanly yet.");
|
|
} else {
|
|
setError(null);
|
|
}
|
|
} catch (err) {
|
|
if (!connected && isAlreadyRunningError(err)) {
|
|
const active = await waitForTunnelStatus(true);
|
|
setConnected(active);
|
|
if (active) {
|
|
setError(null);
|
|
return;
|
|
}
|
|
}
|
|
setError(formatInvokeError(err, "Tunnel action failed"));
|
|
}
|
|
}
|
|
|
|
async function resetEnrollment() {
|
|
try {
|
|
await invoke("clear_session");
|
|
setConnected(false);
|
|
setState(null);
|
|
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(formatInvokeError(err, "Unable to clear local profile"));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="client-shell">
|
|
<div className="app-frame">
|
|
<div className="top-strip">
|
|
<div className="brand-lockup">
|
|
<img src="/icon.png" alt="NexaVPN" />
|
|
<div className="brand-copy">
|
|
<p className="eyebrow">NexaVPN</p>
|
|
<h1>VPN Client</h1>
|
|
<p>{state ? "Private access client" : "Sign in to add this device"}</p>
|
|
</div>
|
|
</div>
|
|
<div className="top-actions">
|
|
{state ? (
|
|
<button className="shell-button-secondary" disabled={syncing} onClick={onSyncProfile} type="button">
|
|
{syncing ? "Syncing..." : "Sync"}
|
|
</button>
|
|
) : null}
|
|
{state ? (
|
|
<button className="shell-button-secondary" onClick={resetEnrollment} type="button">
|
|
Logout
|
|
</button>
|
|
) : null}
|
|
<button className="shell-button" disabled={!state} onClick={toggleConnection} type="button">
|
|
{!state ? "Provision first" : connected ? "Disconnect" : "Connect"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="body-grid">
|
|
{!state ? (
|
|
<section className="login-panel">
|
|
<div className="surface-header">
|
|
<div>
|
|
<p className="eyebrow">Sign in</p>
|
|
<h3>Provision device</h3>
|
|
</div>
|
|
</div>
|
|
<form onSubmit={onSubmit}>
|
|
<label>
|
|
VPN server address
|
|
<input value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} />
|
|
</label>
|
|
<label>
|
|
Username
|
|
<input value={username} onChange={(event) => setUsername(event.target.value)} />
|
|
</label>
|
|
<label>
|
|
Password
|
|
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
|
</label>
|
|
{error ? <div className="error">{error}</div> : null}
|
|
<div className="login-card-actions">
|
|
<button className="shell-button" disabled={loading} type="submit">
|
|
{loading ? "Provisioning..." : "Sign in"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
) : (
|
|
<section className="status-panel">
|
|
<div className="status-top">
|
|
<div>
|
|
<p className="eyebrow">Status</p>
|
|
<h3>{connected ? "Connected" : "Disconnected"}</h3>
|
|
<div className="status-state">
|
|
<span className={`status-dot ${connected ? "online" : ""}`} />
|
|
{connected ? "Tunnel active" : "Ready"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="surface">
|
|
<div className="surface-header">
|
|
<div>
|
|
<p className="eyebrow">Connection</p>
|
|
<h4>Overview</h4>
|
|
</div>
|
|
</div>
|
|
<div className="status-grid">
|
|
<div className="detail-card">
|
|
<span>Assigned VPN IP</span>
|
|
<strong>{state.assignedIp}</strong>
|
|
</div>
|
|
<div className="detail-card">
|
|
<span>Gateway endpoint</span>
|
|
<strong>{state.gatewayEndpoint}</strong>
|
|
</div>
|
|
<div className="detail-card">
|
|
<span>Access</span>
|
|
<strong>{profileLabel}</strong>
|
|
</div>
|
|
<div className="detail-card">
|
|
<span>Last sync</span>
|
|
<strong>{state.lastSyncTime}</strong>
|
|
</div>
|
|
<div className="detail-card">
|
|
<span>Received</span>
|
|
<strong>{formatDataSize(metrics.rxBytes)}</strong>
|
|
</div>
|
|
<div className="detail-card">
|
|
<span>Sent</span>
|
|
<strong>{formatDataSize(metrics.txBytes)}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? <div className="error">{error}</div> : null}
|
|
</section>
|
|
)}
|
|
|
|
<aside className="status-panel">
|
|
<div className="surface-header">
|
|
<div>
|
|
<p className="eyebrow">Access</p>
|
|
<h4>Resources</h4>
|
|
</div>
|
|
</div>
|
|
<p>Press <strong>Sync</strong> after policy changes.</p>
|
|
<ul className="resource-stack">
|
|
{(state?.resources ?? ["No resources assigned yet"]).map((resource) => (
|
|
<li key={resource}>{resource}</li>
|
|
))}
|
|
</ul>
|
|
{state ? (
|
|
<button className="shell-button-secondary" onClick={resetEnrollment} type="button">
|
|
Neu einbinden
|
|
</button>
|
|
) : null}
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|