diff --git a/client/web/src/components/acl-tag.tsx b/client/web/src/components/acl-tag.tsx
new file mode 100644
index 000000000..94522befe
--- /dev/null
+++ b/client/web/src/components/acl-tag.tsx
@@ -0,0 +1,25 @@
+import cx from "classnames"
+import React from "react"
+import Badge from "src/ui/badge"
+
+/**
+ * ACLTag handles the display of an ACL tag.
+ */
+export default function ACLTag({
+ tag,
+ className,
+}: {
+ tag: string
+ className?: string
+}) {
+ return (
+
+ tag:
+ {tag.replace("tag:", "")}
+
+ )
+}
diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx
index 513c938bc..3760f90e5 100644
--- a/client/web/src/components/views/device-details-view.tsx
+++ b/client/web/src/components/views/device-details-view.tsx
@@ -1,8 +1,13 @@
+import cx from "classnames"
import React from "react"
+import { apiFetch } from "src/api"
import { NodeData } from "src/hooks/node-data"
-import ProfilePic from "src/ui/profile-pic"
+import { useLocation } from "wouter"
+import ACLTag from "../acl-tag"
export default function DeviceDetailsView({ node }: { node: NodeData }) {
+ const [, setLocation] = useLocation()
+
return (
Device details
@@ -11,37 +16,36 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
{node.DeviceName}
- {/* TODO: connected status */}
-
+
-
-
-
- Managed by
-
-
- {/* TODO: tags display */}
-
-
- {node.Profile.LoginName}
-
-
General
- {/* TODO: pipe through these values */}
-
- Creator |
- {node.Profile.DisplayName} |
-
-
+
Managed by |
- {node.Profile.DisplayName} |
+
+ {node.IsTagged
+ ? node.Tags.map((t) => )
+ : node.Profile.DisplayName}
+ |
Machine name |
@@ -49,11 +53,11 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
OS |
- MacOS |
+ {node.OS} |
ID |
- nPKyyg3CNTRL |
+ {node.ID} |
Tailscale version |
@@ -61,7 +65,12 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
Key expiry |
- 3 months from now |
+
+ {node.KeyExpired
+ ? "Expired"
+ : // TODO: present as relative expiry (e.g. "5 months from now")
+ new Date(node.KeyExpiry).toLocaleString()}
+ |
@@ -76,7 +85,7 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
Tailscale IPv6 |
- fd7a:115c:a1e0:ab12:4843:cd96:627a:f179 |
+ {node.IPv6} |
Short domain |
@@ -84,7 +93,9 @@ export default function DeviceDetailsView({ node }: { node: NodeData }) {
Full domain |
- {node.DeviceName}.corp.ts.net |
+
+ {node.DeviceName}.{node.TailnetName}
+ |
diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts
index 73657a19f..c2cfa5889 100644
--- a/client/web/src/hooks/node-data.ts
+++ b/client/web/src/hooks/node-data.ts
@@ -3,9 +3,14 @@ import { apiFetch, setUnraidCsrfToken } from "src/api"
export type NodeData = {
Profile: UserProfile
- Status: string
+ Status: NodeState
DeviceName: string
+ OS: string
IP: string
+ IPv6: string
+ ID: string
+ KeyExpiry: string
+ KeyExpired: boolean
AdvertiseExitNode: boolean
AdvertiseRoutes: string
LicensesURL: string
@@ -16,10 +21,21 @@ export type NodeData = {
UnraidToken: string
IPNVersion: string
URLPrefix: string
+ TailnetName: string
+ IsTagged: boolean
+ Tags: string[]
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
}
+type NodeState =
+ | "NoState"
+ | "NeedsLogin"
+ | "NeedsMachineAuth"
+ | "Stopped"
+ | "Starting"
+ | "Running"
+
export type UserProfile = {
LoginName: string
DisplayName: string
diff --git a/client/web/src/index.css b/client/web/src/index.css
index 0cd0aa15c..ff72ac040 100644
--- a/client/web/src/index.css
+++ b/client/web/src/index.css
@@ -26,7 +26,7 @@
@apply flex flex-col gap-2;
}
.card td:first-child {
- @apply w-40 text-neutral-500 text-sm leading-tight;
+ @apply w-40 text-neutral-500 text-sm leading-tight flex-shrink-0;
}
.card td:last-child {
@apply text-neutral-800 text-sm leading-tight;
diff --git a/client/web/src/ui/badge.tsx b/client/web/src/ui/badge.tsx
new file mode 100644
index 000000000..ca211822c
--- /dev/null
+++ b/client/web/src/ui/badge.tsx
@@ -0,0 +1,48 @@
+import cx from "classnames"
+import React, { HTMLAttributes } from "react"
+
+export type BadgeColor =
+ | "blue"
+ | "green"
+ | "red"
+ | "orange"
+ | "yellow"
+ | "gray"
+ | "outline"
+
+type Props = {
+ variant: "tag" | "status"
+ color: BadgeColor
+} & HTMLAttributes
+
+export default function Badge(props: Props) {
+ const { className, color, variant, ...rest } = props
+
+ return (
+
+ )
+}
+
+Badge.defaultProps = {
+ color: "gray",
+}
diff --git a/client/web/web.go b/client/web/web.go
index 271b648ea..d45fd2d9d 100644
--- a/client/web/web.go
+++ b/client/web/web.go
@@ -490,21 +490,35 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
}
type nodeData struct {
- Profile tailcfg.UserProfile
- Status string
- DeviceName string
- IP string
+ ID tailcfg.StableNodeID
+ Status string
+ DeviceName string
+ TailnetName string // TLS cert name
+ IP string // IPv4
+ IPv6 string
+ OS string
+ IPNVersion string
+
+ Profile tailcfg.UserProfile
+ IsTagged bool
+ Tags []string
+
+ KeyExpiry string // time.RFC3339
+ KeyExpired bool
+
+ TUNMode bool
+ IsSynology bool
+ DSMVersion int // 6 or 7, if IsSynology=true
+ IsUnraid bool
+ UnraidToken string
+ URLPrefix string // if set, the URL prefix the client is served behind
+
AdvertiseExitNode bool
AdvertiseRoutes string
- LicensesURL string
- TUNMode bool
- IsSynology bool
- DSMVersion int // 6 or 7, if IsSynology=true
- IsUnraid bool
- UnraidToken string
- IPNVersion string
- DebugMode string // empty when not running in any debug mode
- URLPrefix string // if set, the URL prefix the client is served behind
+
+ LicensesURL string
+
+ DebugMode string // empty when not running in any debug mode
}
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
@@ -518,9 +532,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- profile := st.User[st.Self.UserID]
- deviceName := strings.Split(st.Self.DNSName, ".")[0]
- versionShort := strings.Split(st.Version, "-")[0]
var debugMode string
if s.mode == ManageServerMode {
debugMode = "full"
@@ -528,19 +539,40 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
debugMode = "login"
}
data := &nodeData{
- Profile: profile,
+ ID: st.Self.ID,
Status: st.BackendState,
- DeviceName: deviceName,
- LicensesURL: licenses.LicensesURL(),
+ DeviceName: strings.Split(st.Self.DNSName, ".")[0],
+ TailnetName: st.CurrentTailnet.MagicDNSSuffix,
+ OS: st.Self.OS,
+ IPNVersion: strings.Split(st.Version, "-")[0],
+ Profile: st.User[st.Self.UserID],
+ IsTagged: st.Self.IsTagged(),
+ KeyExpired: st.Self.Expired,
TUNMode: st.TUN,
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
DSMVersion: distro.DSMVersion(),
IsUnraid: distro.Get() == distro.Unraid,
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
- IPNVersion: versionShort,
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
+ LicensesURL: licenses.LicensesURL(),
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
}
+ for _, ip := range st.TailscaleIPs {
+ if ip.Is4() {
+ data.IP = ip.String()
+ } else if ip.Is6() {
+ data.IPv6 = ip.String()
+ }
+ if data.IP != "" && data.IPv6 != "" {
+ break
+ }
+ }
+ if st.Self.Tags != nil {
+ data.Tags = st.Self.Tags.AsSlice()
+ }
+ if st.Self.KeyExpiry != nil {
+ data.KeyExpiry = st.Self.KeyExpiry.Format(time.RFC3339)
+ }
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertiseExitNode = true
@@ -551,9 +583,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
data.AdvertiseRoutes += r.String()
}
}
- if len(st.TailscaleIPs) != 0 {
- data.IP = st.TailscaleIPs[0].String()
- }
writeJSON(w, *data)
}