Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

const callGetStatus = rpc.declare({ object: 'tailscale', method: 'get_status' });
const callGetSettings = rpc.declare({ object: 'tailscale', method: 'get_settings' });
const callSetSettings = rpc.declare({ object: 'tailscale', method: 'set_settings', params: ['form_data'] });
const callDoLogin = rpc.declare({ object: 'tailscale', method: 'do_login', params: ['form_data'] });
const callDoLogout = rpc.declare({ object: 'tailscale', method: 'do_logout' });
const callGetSubroutes = rpc.declare({ object: 'tailscale', method: 'get_subroutes' });
Expand All @@ -24,7 +23,8 @@ const tailscaleSettingsConf = [
[form.Flag, 'nosnat', _('Disable SNAT'), _('Disable Source NAT (SNAT) for traffic to advertised routes. Most users should leave this unchecked.'), { rmempty: false }],
[form.Flag, 'shields_up', _('Shields Up'), _('When enabled, blocks all inbound connections from the Tailscale network.'), { rmempty: false }],
[form.Flag, 'ssh', _('Enable Tailscale SSH'), _('Allow connecting to this device through the SSH function of Tailscale.'), { rmempty: false }],
[form.Flag, 'disable_magic_dns', _('Disable MagicDNS'), _('Use system DNS instead of MagicDNS.'), { rmempty: false }]
[form.Flag, 'disable_magic_dns', _('Disable MagicDNS'), _('Use system DNS instead of MagicDNS.'), { rmempty: false }],
[form.Flag, 'enable_relay', _('Enable Peer Relay'), _('Enable this device as a Peer Relay server. Requires a public IP and an UDP port open on the router.'), { rmempty: false }]
];

const accountConf = []; // dynamic created in render function
Expand Down Expand Up @@ -235,69 +235,66 @@ function renderStatus(status) {
E('tr', {}, statusData.map(item => E('td', { 'style': 'padding-right: 20px;' }, item.value)))
]);

// --- Part 3: Render the Peers/Network Devices table ---
return statusTable;
}

const peers = status.peers;
let peersContent;
function renderDevices(status) {
if (!status || !status.hasOwnProperty('status')) {
return E('em', {}, _('Collecting data ...'));
}

if (status.status != 'running') {
return E('em', {}, _('Tailscale status error'));
}

if (Object.keys(regionCodeMap).length === 0) {
initializeRegionMap();
}

const peers = status.peers;
if (!peers || Object.keys(peers).length === 0) {
// Display a message if no peers are found.
peersContent = E('p', {}, _('No peer devices found.'));
} else {
// Define headers for the peers table.
const peerTableHeaders = [
{ text: _('Status'), style: 'width: 80px;' },
{ text: _('Hostname') },
{ text: _('Tailscale IP') },
{ text: _('OS') },
{ text: _('Connection Info') },
{ text: _('RX') },
{ text: _('TX') },
{ text: _('Last Seen') }
];

// Build the peers table.
peersContent = E('table', { 'class': 'cbi-table' }, [
// Table Header Row
E('tr', { 'class': 'cbi-table-header' }, peerTableHeaders.map(header => {
let th_style = 'padding-right: 20px; text-align: left;';
if (header.style) {
th_style += header.style;
}
return E('th', { 'class': 'cbi-table-cell', 'style': th_style }, header.text);
})),

// Table Body Rows (one for each peer)
...Object.entries(peers).map(([peerid, peer]) => {
const td_style = 'padding-right: 20px;';

return E('tr', { 'class': 'cbi-rowstyle-1' }, [
E('td', { 'class': 'cbi-value-field', 'style': td_style },
E('span', {
'style': `color:${peer.exit_node ? 'blue' : (peer.online ? 'green' : 'gray')};`,
'title': (peer.exit_node ? _('Exit Node') + ' ' : '') + (peer.online ? _('Online') : _('Offline'))
}, peer.online ? '●' : 'β—‹')
),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, E('strong', {}, peer.hostname + (peer.exit_node_option ? ' (ExNode)' : ''))),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, peer.ip || 'N/A'),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, peer.ostype || 'N/A'),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatConnectionInfo(peer.linkadress || '-')),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatBytes(peer.rx)),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatBytes(peer.tx)),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatLastSeen(peer.lastseen))
]);
})
]);
return E('p', {}, _('No peer devices found.'));
}

// Combine all parts into a single DocumentFragment.
// Using E() without a tag name creates a fragment, which is perfect for grouping elements.
return E([
statusTable,
E('div', { 'style': 'margin-top: 25px;' }, [
E('h4', {}, _('Network Devices')),
peersContent
])
const peerTableHeaders = [
{ text: _('Status'), style: 'width: 80px;' },
{ text: _('Hostname') },
{ text: _('Tailscale IP') },
{ text: _('OS') },
{ text: _('Connection Info') },
{ text: _('RX') },
{ text: _('TX') },
{ text: _('Last Seen') }
];

return E('table', { 'class': 'cbi-table' }, [
E('tr', { 'class': 'cbi-table-header' }, peerTableHeaders.map(header => {
let th_style = 'padding-right: 20px; text-align: left;';
if (header.style) {
th_style += header.style;
}
return E('th', { 'class': 'cbi-table-cell', 'style': th_style }, header.text);
})),

...Object.entries(peers).map(([peerid, peer]) => {
const td_style = 'padding-right: 20px;';

return E('tr', { 'class': 'cbi-rowstyle-1' }, [
E('td', { 'class': 'cbi-value-field', 'style': td_style },
E('span', {
'style': `color:${peer.exit_node ? 'blue' : (peer.online ? 'green' : 'gray')};`,
'title': (peer.exit_node ? _('Exit Node') + ' ' : '') + (peer.online ? _('Online') : _('Offline'))
}, peer.online ? '●' : 'β—‹')
),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, E('strong', {}, peer.hostname + (peer.exit_node_option ? ' (ExNode)' : ''))),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, peer.ip || 'N/A'),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, peer.ostype || 'N/A'),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatConnectionInfo(peer.linkadress || '-')),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatBytes(peer.rx)),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatBytes(peer.tx)),
E('td', { 'class': 'cbi-value-field', 'style': td_style }, formatLastSeen(peer.lastseen))
]);
})
]);
}

Expand Down Expand Up @@ -353,6 +350,11 @@ return view.extend({
view.replaceChildren(content);
}

const devicesView = document.getElementById("tailscale_devices_display");
if (devicesView) {
devicesView.replaceChildren(renderDevices(res));
}

// login button only available when logged out
const login_btn=document.getElementsByClassName('cbi-button cbi-button-apply')[0];
if(login_btn) { login_btn.disabled=(res.status != 'logout'); }
Expand All @@ -365,14 +367,22 @@ return view.extend({
}

// Bind settings to the 'settings' section of uci
s = map.section(form.NamedSection, 'settings', 'settings', _('Settings'));
s = map.section(form.NamedSection, 'settings', 'settings', null);
s.dynamic = true;

// Create the "General Settings" tab and apply tailscaleSettingsConf
s.tab('general', _('General Settings'));

defTabOpts(s, 'general', tailscaleSettingsConf, { optional: false });

const relayPort = s.taboption('general', form.Value, 'relay_server_port', _('Peer Relay Port'),
_('UDP port for the Peer Relay service. Open this port on your router firewall/NAT.')
);
relayPort.datatype = 'port';
relayPort.placeholder = '40000';
relayPort.rmempty = false;
relayPort.depends('enable_relay', '1');

const en = s.taboption('general', form.ListValue, 'exit_node', _('Exit Node'), _('Select an exit node from the list. If enabled, Allow LAN Access is enabled implicitly.'));
en.value('', _('None'));
if (status.peers) {
Expand Down Expand Up @@ -541,47 +551,25 @@ return view.extend({
ui.showModal(_('Confirm Logout'), confirmationContent);
};

s.tab('devices', _('Devices List'));
const devicesSection = s.taboption('devices', form.DummyValue, '_devices');
devicesSection.render = function () {
return E('div', { 'id': 'tailscale_devices_display', 'class': 'cbi-value' }, renderDevices(status));
};

// Create the "Daemon Settings" tab and apply daemonConf
//s.tab('daemon', _('Daemon Settings'));
//defTabOpts(s, 'daemon', daemonConf, { optional: false });

return map.render();
},

// The handleSaveApply function is executed after clicking "Save & Apply"
handleSaveApply(ev) {
// The handleSaveApply function saves UCI changes then applies them via the
// standard OpenWrt apply mechanism, which triggers /etc/init.d/tailscale-settings
// and provides automatic rollback protection if the device becomes unreachable.
handleSaveApply(ev, mode) {
return map.save().then(function () {
const data = map.data.get('tailscale', 'settings');

// fix empty value issue
if(!data.advertise_exit_node) data.advertise_exit_node = '';
if(!data.advertise_routes) data.advertise_routes = '';
if(!data.exit_node) data.exit_node = '';
if(!data.custom_login_url) data.custom_login_url = '';
if(!data.custom_login_AuthKey) data.custom_login_AuthKey = '';

ui.showModal(_('Applying changes...'), E('em', {}, _('Please wait.')));

return callSetSettings(data).then(function (response) {
if (response.success) {
ui.hideModal();
setTimeout(function() {
ui.addTimeLimitedNotification(null, [ E('p', _('Tailscale settings applied successfully.')) ], 5000, 'info');
}, 1000);
try {
L.ui.changes.revert();
} catch (error) {
ui.addTimeLimitedNotification(null, [ E('p', _('Error saving settings: %s').format(error || _('Unknown error'))) ], 7000, 'error');
}
} else {
ui.hideModal();
ui.addTimeLimitedNotification(null, [ E('p', _('Error applying settings: %s').format(response.error || _('Unknown error'))) ], 7000, 'error');
}
});
}).catch(function(err) {
ui.hideModal();
//console.error('Save failed:', err);
ui.addTimeLimitedNotification(null, [ E('p', _('Failed to save settings: %s').format(err.message)) ], 7000, 'error');
return ui.changes.apply(mode == '0');
});
},

Expand Down
Loading
Loading