From abd5022e18cf42d14af4b35ab772027660b7d60a Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 30 Jul 2026 14:38:54 +0200 Subject: [PATCH] Flag active VNC sessions on the main screen --- .../modules/main/ActiveSessionIndicator.tsx | 54 +++++++++++++++++++ .../main/MainConnectionStatusSwitch.tsx | 4 ++ client/ui/i18n/locales/en/common.json | 12 +++++ client/ui/services/daemon_feed.go | 22 ++++++++ 4 files changed, 92 insertions(+) create mode 100644 client/ui/frontend/src/modules/main/ActiveSessionIndicator.tsx diff --git a/client/ui/frontend/src/modules/main/ActiveSessionIndicator.tsx b/client/ui/frontend/src/modules/main/ActiveSessionIndicator.tsx new file mode 100644 index 000000000..3fda0041f --- /dev/null +++ b/client/ui/frontend/src/modules/main/ActiveSessionIndicator.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from "react-i18next"; +import { CircleAlert } from "lucide-react"; +import { Tooltip } from "@/components/Tooltip"; +import { useStatus } from "@/contexts/StatusContext.tsx"; +import { cn } from "@/lib/cn.ts"; +import { type ReactNode } from "react"; + +// ActiveSessionIndicator marks that someone is attached to this machine over +// VNC right now, and warns that disconnecting ends that session — which may be +// the session the person reading it is using. +// +// An alert glyph rather than an info one: this is not neutral context, it is a +// state that changes what the disconnect button does to you. +// +// Only a warning, never a block. Input injected by the VNC agent is +// indistinguishable from local input, so we cannot tell whether this UI is being +// driven remotely, and the owner may want to disconnect on purpose either way. +// +// Renders nothing when no session is attached. +export function ActiveSessionIndicator({ className }: { className?: string }): ReactNode { + const { t } = useTranslation(); + const { status } = useStatus(); + const sessions = status?.vncSessions ?? []; + if (sessions.length === 0) return null; + + // The initiator is the dashboard user who started the session, which is more + // use than a source address. Absent when the session carries no identity. + const who = sessions + .map((s) => s.initiator) + .filter((name): name is string => !!name) + .join(", "); + + const message = who + ? t("connect.activeSession.tooltipNamed", { sessionCount: sessions.length, who }) + : t("connect.activeSession.tooltip", { sessionCount: sessions.length }); + + return ( + {message}}> + + + + {t("connect.activeSession.badge")} + + + + ); +} diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index c2a648278..02377ab37 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -20,6 +20,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible"; import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react"; import * as Popover from "@radix-ui/react-popover"; import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; +import { ActiveSessionIndicator } from "@/modules/main/ActiveSessionIndicator.tsx"; enum ConnectionState { Disconnected = "disconnected", @@ -272,6 +273,9 @@ export const MainConnectionStatusSwitch = () => { /> + {connState === ConnectionState.Connected && ( + + )} ); diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 57461e2a9..e4fd01ef3 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1855,6 +1855,18 @@ "message": "Operation failed.", "description": "Generic fallback error message used when no specific error applies." }, + "connect.activeSession.badge": { + "message": "Screen shared", + "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." + }, + "connect.activeSession.tooltip": { + "message": "This screen is being viewed over VNC ({sessionCount} session(s)). Disconnecting ends it, and if you are connected through VNC you will lose access.", + "description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms." + }, + "connect.activeSession.tooltipNamed": { + "message": "This screen is being viewed over VNC by {who} ({sessionCount} session(s)). Disconnecting ends it, and if you are connected through VNC you will lose access.", + "description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions." + }, "settings.privilege.hint": { "message": "Requires {actor}. Run this instead:", "description": "Help text under a remote-access setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go index 632581fe9..10b54a941 100644 --- a/client/ui/services/daemon_feed.go +++ b/client/ui/services/daemon_feed.go @@ -106,6 +106,18 @@ type LocalPeer struct { Networks []string `json:"networks"` } +// VNCSession describes one VNC connection currently attached to this machine. +// Surfaced so the UI can warn before an action ends a session that may be the +// one the person is using: input injected by the VNC agent is indistinguishable +// from local input, so the UI cannot tell whether it is being driven remotely. +type VNCSession struct { + RemoteAddress string `json:"remoteAddress"` + Mode string `json:"mode"` + // Initiator is the display name of the dashboard user who started the + // session, when known. + Initiator string `json:"initiator"` +} + // Status is the snapshot the frontend renders on the dashboard. type Status struct { Status string `json:"status"` @@ -122,6 +134,8 @@ type Status struct { // SessionExpiresAt is the absolute UTC instant the SSO session expires; nil // when the peer is not SSO-tracked or login expiration is disabled. SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` + // VNCSessions lists the VNC connections currently attached to this machine. + VNCSessions []VNCSession `json:"vncSessions,omitempty"` } // DaemonFeed fans the daemon's two long-running gRPC streams (SubscribeStatus, @@ -524,6 +538,14 @@ func statusFromProto(resp *proto.StatusResponse) Status { }, } + for _, v := range full.GetVncServerState().GetSessions() { + st.VNCSessions = append(st.VNCSessions, VNCSession{ + RemoteAddress: v.GetRemoteAddress(), + Mode: v.GetMode(), + Initiator: v.GetInitiator(), + }) + } + for _, p := range full.GetPeers() { st.Peers = append(st.Peers, PeerStatus{ IP: p.GetIP(),