Files
netbird/client/ui-wails/frontend/src/pages/Profiles.tsx
Zoltán Papp 504dceedf3 [client] Add Wails3 + React desktop UI scaffold
Stage 1 of the client/ui (Fyne) replacement. Adds a new client/ui-wails
module that runs on Linux/macOS/Windows from a single React + Vite +
Tailwind frontend driven by a thin gRPC services layer in Go.

- Single-module integration (no submodule): merge Wails3 into root go.mod
  with build tags !android !ios !freebsd !js so cross-compiles on those
  targets exclude the package automatically.
- Seven gRPC-bound services: Connection, Settings, Networks, Profiles,
  Debug, Update, Peers. Peers bridges Status polling and SubscribeEvents
  to the Wails event bus (netbird:status, netbird:event).
- Tray + window shell mirrors the Fyne menu 1:1 with hide-on-close,
  SIGUSR1 / Windows named-event for external "show window" triggers.
- React pages cover functional parity for Status, Settings (3 tabs),
  Networks (3 tabs), Profiles, Debug, Update, QuickActions, LoginUrl.
- SVG-sourced tray icons (12 source SVGs incl. macOS template variants)
  rasterized to PNG via task common:generate:tray:icons.
- Linux launcher sets WEBKIT_DISABLE_DMABUF_RENDERER=1 in the .desktop
  Exec= line and in task linux:run so the app renders correctly under
  RDP, VirtualBox, KVM, and bare WMs (Fluxbox/dwm) without DRM access.
2026-04-29 11:10:23 +02:00

174 lines
4.9 KiB
TypeScript

import { FormEvent, useCallback, useEffect, useState } from "react";
import { Plus, RefreshCw } from "lucide-react";
import {
Profiles as ProfilesSvc,
Connection,
} from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import { Card } from "../components/Card";
export default function Profiles() {
const [username, setUsername] = useState("");
const [profiles, setProfiles] = useState<Profile[]>([]);
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const refresh = useCallback(async () => {
try {
const u = username || (await ProfilesSvc.Username());
if (!username) setUsername(u);
const list = await ProfilesSvc.List(u);
setProfiles(list);
setError(null);
} catch (e) {
setError(String(e));
}
}, [username]);
useEffect(() => {
refresh();
}, [refresh]);
const select = async (name: string) => {
try {
await ProfilesSvc.Switch({ profileName: name, username });
await Connection.Up({ profileName: name, username });
await refresh();
} catch (e) {
setError(String(e));
}
};
const deregister = async (name: string) => {
try {
await Connection.Logout({ profileName: name, username });
await refresh();
} catch (e) {
setError(String(e));
}
};
const remove = async (name: string) => {
if (name === "default") return;
try {
await ProfilesSvc.Remove({ profileName: name, username });
await refresh();
} catch (e) {
setError(String(e));
}
};
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Profiles</h1>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={refresh}>
<RefreshCw className="h-3.5 w-3.5" strokeWidth={1.5} /> Refresh
</Button>
<Button size="sm" onClick={() => setAdding(true)}>
<Plus className="h-3.5 w-3.5" strokeWidth={1.5} /> Add
</Button>
</div>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="space-y-2">
{profiles.map((p) => (
<Card key={p.name} className="flex items-center gap-3">
<input
type="radio"
name="active-profile"
checked={p.isActive}
onChange={() => select(p.name)}
className="h-4 w-4 accent-netbird"
/>
<div className="flex-1">
<p className="text-sm font-medium">{p.name}</p>
{p.isActive && <p className="text-xs text-nb-gray-500">Active</p>}
</div>
<Button size="sm" variant="ghost" onClick={() => deregister(p.name)}>
Deregister
</Button>
<Button
size="sm"
variant="danger"
disabled={p.name === "default"}
onClick={() => remove(p.name)}
>
Remove
</Button>
</Card>
))}
{profiles.length === 0 && (
<p className="text-sm text-nb-gray-500">No profiles.</p>
)}
</div>
{adding && (
<AddDialog
username={username}
onClose={() => setAdding(false)}
onAdded={async () => {
setAdding(false);
await refresh();
}}
/>
)}
</div>
);
}
function AddDialog({
username,
onClose,
onAdded,
}: {
username: string;
onClose: () => void;
onAdded: () => void;
}) {
const [name, setName] = useState("");
const [err, setErr] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await ProfilesSvc.Add({ profileName: name.trim(), username });
onAdded();
} catch (e) {
setErr(String(e));
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<form
onSubmit={submit}
className="w-80 rounded-lg border border-nb-gray-200 bg-white p-4 shadow-lg dark:border-nb-gray-800 dark:bg-nb-gray-925"
>
<h2 className="mb-3 text-base font-semibold">New profile</h2>
<Input
autoFocus
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{err && <p className="mt-2 text-xs text-red-500">{err}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button type="submit" size="sm">
Add
</Button>
</div>
</form>
</div>
);
}