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(),