mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-06 10:00:18 +02:00
Group, search and filter the device list
A tailnet with a VPN add-on has hundreds of exit servers among its peers, which buried the real devices on the status page. - Devices are grouped: this tailnet, shared with you, VPN exit servers. - VPN exit servers are counted but only listed, and only sent to the page, when asked for. - Search by name, address, OS, tag or place, and an online-only toggle. - Devices that offer exit-node service are marked. - The count and the streaming host suggestions leave exit servers out.
This commit is contained in:
1 parent
ed67b35b52
commit
558994fc76
5 files changed
+328
-48
No files matched your search
@@ -125,6 +125,13 @@ login link and your devices, and has the game streaming hosts, the settings,
|
||||
and buttons for logging out, stopping and uninstalling. It also says when a
|
||||
newer release is available.
|
||||
|
||||
**Devices.** The list is grouped into your tailnet's devices and devices
|
||||
shared with you, and marks the ones that can be used as an exit node. It can
|
||||
be searched (name, address, OS, tag, place) and limited to devices that are
|
||||
online. If your tailnet has a VPN add-on such as Mullvad, its exit servers
|
||||
are counted but kept out of the list until you tick **Show VPN exit
|
||||
servers**.
|
||||
|
||||
**Password.** Out of the box the page has no password, like the console's
|
||||
other homebrew services: anyone on your LAN, or on your tailnet if your ACLs
|
||||
allow it, can use it. Set one under **Settings**. It is then asked for on
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
// The device list of the status page. A tailnet with a VPN add-on has
|
||||
// hundreds of exit servers among its peers, so peers are sorted into kinds
|
||||
// and the exit servers are only sent to the page when it asks for them.
|
||||
|
||||
const (
|
||||
peerOwn = "own" // a device of this tailnet
|
||||
peerShared = "shared" // a device of another tailnet, shared with this one
|
||||
peerVPN = "vpn" // an exit server of a VPN add-on
|
||||
)
|
||||
|
||||
// vpnDomains are the DNS suffixes of the exit servers that VPN add-ons put
|
||||
// in a tailnet. Tailscale's own "status" command hides them the same way.
|
||||
var vpnDomains = []string{"mullvad.ts.net"}
|
||||
|
||||
type peerInfo struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
OS string `json:"os"`
|
||||
Online bool `json:"online"`
|
||||
Kind string `json:"kind"`
|
||||
// ExitNode is "offered" for a device that can be used as an exit node
|
||||
// and "used" for the one this console uses.
|
||||
ExitNode string `json:"exitNode,omitempty"`
|
||||
// Location is where an exit server says it is ("Vienna, Austria").
|
||||
Location string `json:"location,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// peerCount counts the peers of one kind.
|
||||
type peerCount struct {
|
||||
Total int `json:"total"`
|
||||
Online int `json:"online"`
|
||||
}
|
||||
|
||||
func hasDNSSuffix(name, suffix string) bool {
|
||||
name = strings.ToLower(strings.TrimSuffix(name, "."))
|
||||
suffix = strings.ToLower(strings.Trim(suffix, "."))
|
||||
return suffix != "" && (name == suffix || strings.HasSuffix(name, "."+suffix))
|
||||
}
|
||||
|
||||
// peerKind sorts a peer into one of the kinds. suffix is this tailnet's
|
||||
// MagicDNS suffix.
|
||||
func peerKind(p *ipnstate.PeerStatus, suffix string) string {
|
||||
if p.ExitNodeOption || p.ExitNode {
|
||||
for _, d := range vpnDomains {
|
||||
if hasDNSSuffix(p.DNSName, d) {
|
||||
return peerVPN
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.DNSName != "" && suffix != "" && !hasDNSSuffix(p.DNSName, suffix) {
|
||||
return peerShared
|
||||
}
|
||||
return peerOwn
|
||||
}
|
||||
|
||||
func newPeerInfo(p *ipnstate.PeerStatus, suffix string) peerInfo {
|
||||
pi := peerInfo{Name: p.HostName, OS: p.OS, Online: p.Online, Kind: peerKind(p, suffix)}
|
||||
if p.DNSName != "" {
|
||||
pi.Name = strings.SplitN(p.DNSName, ".", 2)[0]
|
||||
}
|
||||
if len(p.TailscaleIPs) > 0 {
|
||||
pi.IP = p.TailscaleIPs[0].String()
|
||||
}
|
||||
switch {
|
||||
case p.ExitNode:
|
||||
pi.ExitNode = "used"
|
||||
case p.ExitNodeOption:
|
||||
pi.ExitNode = "offered"
|
||||
}
|
||||
if l := p.Location; l != nil {
|
||||
parts := []string{}
|
||||
for _, s := range []string{l.City, l.Country} {
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
pi.Location = strings.Join(parts, ", ")
|
||||
}
|
||||
if p.Tags != nil {
|
||||
pi.Tags = p.Tags.AsSlice()
|
||||
}
|
||||
return pi
|
||||
}
|
||||
|
||||
// peersFromStatus lists the peers for the status page, online ones first.
|
||||
// VPN exit servers are counted, and listed only if withVPN is set; the one
|
||||
// in use is always listed.
|
||||
func peersFromStatus(st *ipnstate.Status, withVPN bool) (peers []peerInfo, vpn peerCount) {
|
||||
peers = []peerInfo{}
|
||||
for _, p := range st.Peer {
|
||||
pi := newPeerInfo(p, st.MagicDNSSuffix)
|
||||
if pi.Kind == peerVPN {
|
||||
vpn.Total++
|
||||
if pi.Online {
|
||||
vpn.Online++
|
||||
}
|
||||
if !withVPN && pi.ExitNode != "used" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
peers = append(peers, pi)
|
||||
}
|
||||
sort.Slice(peers, func(i, j int) bool {
|
||||
if peers[i].Online != peers[j].Online {
|
||||
return peers[i].Online
|
||||
}
|
||||
return peers[i].Name < peers[j].Name
|
||||
})
|
||||
return peers, vpn
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
func testStatus() *ipnstate.Status {
|
||||
tags := views.SliceOf([]string{"tag:server"})
|
||||
peers := []*ipnstate.PeerStatus{
|
||||
{HostName: "Desk PC", DNSName: "desk.tail1234.ts.net.", OS: "windows", Online: true,
|
||||
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.2")}},
|
||||
{HostName: "nas", DNSName: "nas.tail1234.ts.net.", OS: "linux", ExitNodeOption: true, Tags: &tags},
|
||||
{HostName: "friend", DNSName: "laptop.tail9999.ts.net.", OS: "macOS", Online: true},
|
||||
{HostName: "at-vie-wg-001", DNSName: "at-vie-wg-001.mullvad.ts.net.", Online: true, ExitNodeOption: true,
|
||||
Location: &tailcfg.Location{Country: "Austria", City: "Vienna"}},
|
||||
{HostName: "se-sto-wg-001", DNSName: "se-sto-wg-001.mullvad.ts.net.", ExitNodeOption: true},
|
||||
}
|
||||
st := &ipnstate.Status{MagicDNSSuffix: "tail1234.ts.net", Peer: map[key.NodePublic]*ipnstate.PeerStatus{}}
|
||||
for _, p := range peers {
|
||||
st.Peer[key.NewNode().Public()] = p
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func TestPeersFromStatus(t *testing.T) {
|
||||
peers, vpn := peersFromStatus(testStatus(), false)
|
||||
if vpn != (peerCount{Total: 2, Online: 1}) {
|
||||
t.Errorf("vpn count = %+v", vpn)
|
||||
}
|
||||
// Online first, then by name; no VPN servers.
|
||||
want := []peerInfo{
|
||||
{Name: "desk", IP: "100.64.0.2", OS: "windows", Online: true, Kind: peerOwn},
|
||||
{Name: "laptop", OS: "macOS", Online: true, Kind: peerShared},
|
||||
{Name: "nas", OS: "linux", Kind: peerOwn, ExitNode: "offered", Tags: []string{"tag:server"}},
|
||||
}
|
||||
if len(peers) != len(want) {
|
||||
t.Fatalf("got %d peers, want %d: %+v", len(peers), len(want), peers)
|
||||
}
|
||||
for i := range want {
|
||||
g, w := peers[i], want[i]
|
||||
if g.Name != w.Name || g.IP != w.IP || g.OS != w.OS || g.Online != w.Online || g.Kind != w.Kind ||
|
||||
g.ExitNode != w.ExitNode || len(g.Tags) != len(w.Tags) {
|
||||
t.Errorf("peer %d = %+v, want %+v", i, g, w)
|
||||
}
|
||||
}
|
||||
|
||||
peers, _ = peersFromStatus(testStatus(), true)
|
||||
if len(peers) != 5 {
|
||||
t.Fatalf("with VPN servers: got %d peers, want 5", len(peers))
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p.Name == "at-vie-wg-001" && (p.Kind != peerVPN || p.Location != "Vienna, Austria" || p.ExitNode != "offered") {
|
||||
t.Errorf("VPN server = %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitServerInUseIsAlwaysListed(t *testing.T) {
|
||||
st := testStatus()
|
||||
for _, p := range st.Peer {
|
||||
if p.HostName == "se-sto-wg-001" {
|
||||
p.ExitNode = true
|
||||
}
|
||||
}
|
||||
peers, _ := peersFromStatus(st, false)
|
||||
found := false
|
||||
for _, p := range peers {
|
||||
if p.Name == "se-sto-wg-001" {
|
||||
found = p.Kind == peerVPN && p.ExitNode == "used"
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the exit server in use is missing: %+v", peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerKind(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
p ipnstate.PeerStatus
|
||||
want string
|
||||
}{
|
||||
{"own", ipnstate.PeerStatus{DNSName: "a.tail1234.ts.net."}, peerOwn},
|
||||
{"own exit node", ipnstate.PeerStatus{DNSName: "a.tail1234.ts.net.", ExitNodeOption: true}, peerOwn},
|
||||
{"no DNS name", ipnstate.PeerStatus{HostName: "a"}, peerOwn},
|
||||
{"shared", ipnstate.PeerStatus{DNSName: "a.other.ts.net."}, peerShared},
|
||||
{"suffix must match a whole label", ipnstate.PeerStatus{DNSName: "a.xtail1234.ts.net."}, peerShared},
|
||||
{"vpn", ipnstate.PeerStatus{DNSName: "x.mullvad.ts.net.", ExitNodeOption: true}, peerVPN},
|
||||
{"vpn domain but no exit node", ipnstate.PeerStatus{DNSName: "x.mullvad.ts.net."}, peerShared},
|
||||
} {
|
||||
if got := peerKind(&tt.p, "tail1234.ts.net"); got != tt.want {
|
||||
t.Errorf("%s: got %s, want %s", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
// Without a suffix (not logged in yet) nothing is taken for shared.
|
||||
if got := peerKind(&ipnstate.PeerStatus{DNSName: "a.other.ts.net."}, ""); got != peerOwn {
|
||||
t.Errorf("no suffix: got %s", got)
|
||||
}
|
||||
}
|
||||
+91
-23
@@ -57,6 +57,7 @@
|
||||
pre { margin: 0; max-height: 320px; overflow: auto; font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
details summary { cursor: pointer; color: var(--muted); }
|
||||
summary .count { font-size: 14px; margin-left: 8px; }
|
||||
summary .count { white-space: nowrap; }
|
||||
details[open] > table, details[open] > form { margin-top: 12px; }
|
||||
.note { color: var(--muted); font-size: 14px; margin: 0 0 12px; }
|
||||
.hint { color: var(--muted); font-size: 13px; margin: 2px 0 0; }
|
||||
@@ -67,6 +68,14 @@
|
||||
background: var(--panel); color: var(--text); width: 100%;
|
||||
}
|
||||
textarea { font: 14px ui-monospace, Consolas, monospace; min-height: 70px; resize: vertical; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; margin-top: 12px; }
|
||||
.filters input[type=search] {
|
||||
font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
|
||||
background: var(--panel); color: var(--text); flex: 1 1 200px; min-width: 0;
|
||||
}
|
||||
.filters label.check { margin: 0; font-size: 14px; }
|
||||
tr.group td { color: var(--muted); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; padding-top: 14px; border-top: 0; }
|
||||
td .tag { display: block; color: var(--muted); font-size: 13px; }
|
||||
.hostrow { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||
.hostrow .host { flex: 3; min-width: 0; }
|
||||
.hostrow .port { flex: 1; min-width: 90px; }
|
||||
@@ -111,10 +120,16 @@
|
||||
<section class="panel hidden" id="peerpanel">
|
||||
<details id="peerbox" open>
|
||||
<summary><span class="heading">Devices on your tailnet</span><span class="count" id="peercount"></span></summary>
|
||||
<div class="filters">
|
||||
<input type="search" id="peersearch" placeholder="Search name, address, OS, tag or place" aria-label="Search devices" autocomplete="off">
|
||||
<label class="check"><input type="checkbox" id="peeronline"> Online only</label>
|
||||
<label class="check hidden" id="peervpn-row"><input type="checkbox" id="peervpn"> <span id="peervpn-text"></span></label>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead>
|
||||
<tbody id="peers"></tbody>
|
||||
</table>
|
||||
<p class="note hidden" id="peernone" style="margin: 12px 0 0">No device matches.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -264,10 +279,67 @@ function hostRow(host, port) {
|
||||
return div;
|
||||
}
|
||||
|
||||
// The device list. Peers come sorted from the daemon (online first); the page
|
||||
// groups them by kind and applies the filters. Exit servers of a VPN add-on
|
||||
// can be hundreds, so they are only requested while their box is ticked.
|
||||
const kinds = [['own', 'This tailnet'], ['shared', 'Shared with you'], ['vpn', 'VPN exit servers']];
|
||||
let peers = [];
|
||||
let shownPeers = '';
|
||||
|
||||
function stored(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
|
||||
function store(key, value) { try { localStorage.setItem(key, value); } catch (e) {} }
|
||||
|
||||
function peerMatches(p, words) {
|
||||
const text = [p.name, p.ip, p.os, p.location || '', (p.tags || []).join(' '), p.exitNode ? 'exit node' : '',
|
||||
p.online ? 'online' : 'offline'].join(' ').toLowerCase();
|
||||
return words.every(w => text.includes(w));
|
||||
}
|
||||
|
||||
function peerRow(p) {
|
||||
const tr = document.createElement('tr');
|
||||
const notes = [];
|
||||
if (p.exitNode === 'used') notes.push('exit node in use');
|
||||
else if (p.exitNode) notes.push('exit node');
|
||||
if (p.location) notes.push(p.location);
|
||||
if (p.tags) notes.push(p.tags.join(', '));
|
||||
for (const [v, mono, note] of [[p.name, false, notes.join(' · ')], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
|
||||
const td = document.createElement('td');
|
||||
if (!p.online) td.className = 'off';
|
||||
if (mono) { const c = document.createElement('code'); c.textContent = v; td.append(c); } else td.textContent = v;
|
||||
if (note) { const n = document.createElement('span'); n.className = 'tag'; n.textContent = note; td.append(n); }
|
||||
tr.append(td);
|
||||
}
|
||||
return tr;
|
||||
}
|
||||
|
||||
function renderPeers() {
|
||||
const words = $('peersearch').value.toLowerCase().split(/\s+/).filter(w => w);
|
||||
const onlineOnly = $('peeronline').checked;
|
||||
const shown = peers.filter(p => (!onlineOnly || p.online) && peerMatches(p, words));
|
||||
// Rebuilding hundreds of rows every few seconds is wasteful; only do it
|
||||
// when what is shown changed.
|
||||
const key = JSON.stringify(shown);
|
||||
if (key === shownPeers) return;
|
||||
shownPeers = key;
|
||||
const groups = kinds.map(([kind, title]) => [title, shown.filter(p => p.kind === kind)]).filter(g => g[1].length);
|
||||
const rows = [];
|
||||
for (const [title, list] of groups) {
|
||||
if (groups.length > 1) {
|
||||
const tr = document.createElement('tr'); tr.className = 'group';
|
||||
const td = document.createElement('td'); td.colSpan = 4;
|
||||
td.textContent = title + ' (' + list.length + ')';
|
||||
tr.append(td); rows.push(tr);
|
||||
}
|
||||
rows.push(...list.map(peerRow));
|
||||
}
|
||||
$('peers').replaceChildren(...rows);
|
||||
$('peernone').classList.toggle('hidden', shown.length > 0);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let s;
|
||||
try {
|
||||
s = await (await api('/api/status')).json();
|
||||
s = await (await api('/api/status' + ($('peervpn').checked ? '?vpn=1' : ''))).json();
|
||||
} catch (e) {
|
||||
if (e.message !== 'locked') {
|
||||
$('state').textContent = 'Not responding';
|
||||
@@ -309,24 +381,18 @@ async function refresh() {
|
||||
if (shownQR !== s.authURL) { shownQR = s.authURL; $('qr').src = '/qr.png?' + Date.now(); }
|
||||
}
|
||||
|
||||
const tbody = $('peers');
|
||||
tbody.replaceChildren(...s.peers.map(p => {
|
||||
const tr = document.createElement('tr');
|
||||
for (const [v, mono] of [[p.name], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
|
||||
const td = document.createElement('td');
|
||||
if (!p.online) td.className = 'off';
|
||||
if (mono) { const c = document.createElement('code'); c.textContent = v; td.append(c); } else td.textContent = v;
|
||||
tr.append(td);
|
||||
}
|
||||
return tr;
|
||||
}));
|
||||
$('peerpanel').classList.toggle('hidden', !s.peers.length);
|
||||
$('peercount').textContent = s.peers.filter(p => p.online).length + ' online of ' + s.peers.length;
|
||||
peers = s.peers;
|
||||
const devices = peers.filter(p => p.kind !== 'vpn');
|
||||
$('peerpanel').classList.toggle('hidden', !peers.length && !s.vpnServers.total);
|
||||
$('peercount').textContent = devices.filter(p => p.online).length + ' online of ' + devices.length;
|
||||
$('peervpn-row').classList.toggle('hidden', !s.vpnServers.total);
|
||||
$('peervpn-text').textContent = 'Show VPN exit servers (' + s.vpnServers.total + ')';
|
||||
renderPeers();
|
||||
|
||||
// Game streaming. The rows are only rebuilt from the daemon's state while
|
||||
// the user is not in the middle of editing them.
|
||||
$('streampanel').classList.toggle('hidden', s.state !== 'Running' && !s.sunshineHosts.length);
|
||||
$('peernames').replaceChildren(...s.peers.map(p => { const o = document.createElement('option'); o.value = p.name; return o; }));
|
||||
$('peernames').replaceChildren(...devices.map(p => { const o = document.createElement('option'); o.value = p.name; return o; }));
|
||||
const hostsNow = JSON.stringify(s.sunshineHosts);
|
||||
if (!hostsDirty && hostsNow !== lastHosts) {
|
||||
lastHosts = hostsNow;
|
||||
@@ -458,14 +524,16 @@ $('settingsform').onsubmit = async ev => {
|
||||
|
||||
$('logbox').addEventListener('toggle', refresh);
|
||||
|
||||
// The device list can be long. Remember whether it was left collapsed; the
|
||||
// browser may not allow storage, in which case it simply starts open.
|
||||
try {
|
||||
if (localStorage.getItem('peersCollapsed') === '1') $('peerbox').open = false;
|
||||
} catch (e) {}
|
||||
$('peerbox').addEventListener('toggle', () => {
|
||||
try { localStorage.setItem('peersCollapsed', $('peerbox').open ? '0' : '1'); } catch (e) {}
|
||||
});
|
||||
// The device list can be long. Remember whether it was left collapsed and
|
||||
// how it was filtered; the browser may not allow storage, in which case it
|
||||
// simply starts open and unfiltered.
|
||||
if (stored('peersCollapsed') === '1') $('peerbox').open = false;
|
||||
$('peerbox').addEventListener('toggle', () => store('peersCollapsed', $('peerbox').open ? '0' : '1'));
|
||||
$('peeronline').checked = stored('peersOnlineOnly') === '1';
|
||||
$('peervpn').checked = stored('peersShowVPN') === '1';
|
||||
$('peersearch').oninput = renderPeers;
|
||||
$('peeronline').onchange = () => { store('peersOnlineOnly', $('peeronline').checked ? '1' : '0'); renderPeers(); };
|
||||
$('peervpn').onchange = () => { store('peersShowVPN', $('peervpn').checked ? '1' : '0'); refresh(); };
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 3000);
|
||||
|
||||
+5
-25
@@ -8,7 +8,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,13 +28,6 @@ var faviconPNG []byte
|
||||
// console out. Who may use the page at all is decided in auth.go.
|
||||
const apiHeader = "X-PS5-Tailscale"
|
||||
|
||||
type peerInfo struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
OS string `json:"os"`
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
|
||||
// sunshineInfo is a forwarded Sunshine host as the status page shows it.
|
||||
type sunshineInfo struct {
|
||||
Host string `json:"host"`
|
||||
@@ -55,7 +47,10 @@ type statusInfo struct {
|
||||
Tailnet string `json:"tailnet,omitempty"`
|
||||
Health []string `json:"health,omitempty"`
|
||||
Peers []peerInfo `json:"peers"`
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
// VPNServers counts the exit servers of a VPN add-on. They are only in
|
||||
// Peers when the page asks for them (?vpn=1).
|
||||
VPNServers peerCount `json:"vpnServers"`
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
// SunshineHosts and Forwards describe the local forwards.
|
||||
SunshineHosts []sunshineInfo `json:"sunshineHosts"`
|
||||
Forwards []string `json:"forwards"`
|
||||
@@ -225,22 +220,7 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
info.IPs = append(info.IPs, ip.String())
|
||||
}
|
||||
}
|
||||
for _, p := range st.Peer {
|
||||
pi := peerInfo{Name: p.HostName, OS: p.OS, Online: p.Online}
|
||||
if p.DNSName != "" {
|
||||
pi.Name = strings.SplitN(p.DNSName, ".", 2)[0]
|
||||
}
|
||||
if len(p.TailscaleIPs) > 0 {
|
||||
pi.IP = p.TailscaleIPs[0].String()
|
||||
}
|
||||
info.Peers = append(info.Peers, pi)
|
||||
}
|
||||
sort.Slice(info.Peers, func(i, j int) bool {
|
||||
if info.Peers[i].Online != info.Peers[j].Online {
|
||||
return info.Peers[i].Online
|
||||
}
|
||||
return info.Peers[i].Name < info.Peers[j].Name
|
||||
})
|
||||
info.Peers, info.VPNServers = peersFromStatus(st, r.URL.Query().Get("vpn") == "1")
|
||||
}
|
||||
}
|
||||
if info.State == "Running" {
|
||||
|
||||
Reference in new issue
Block a user