feat(remote): add support for custom tailscale control servers (#1312)

* feat(tailscale): add custom control URL configuration & handling w/ tests
- Introduced TailscaleControlURL in the Config struct to allow configuration of the Tailscale control server.
- Added RPC handlers for getting and setting the Tailscale control URL.
- Updated TailscaleStatus to include controlURL, ensuring it reflects the configured or default value.
- Enhanced parsing and normalization of the control URL to enforce valid formats.
- Updated TailscaleCard component to manage and display the control server URL, allowing users to save changes.

* docs: update README to include optional Tailscale networking feature
Added a new section highlighting the built-in Tailscale status and control-server configuration, including support for custom Headscale-compatible endpoints.

* docs: Extend DEVELOPMENT.md with Tailscale control server details

* fix(tailscale): enhance error handling in rpcSetTailscaleControlURL

- Updated the rpcSetTailscaleControlURL function to revert the TailscaleControlURL to its previous value if saving or applying the new URL fails.
- Added a new test to ensure that the configuration is not saved when the apply command fails, verifying that the previous URL remains intact.
- Adjusted existing tests to validate the order of operations during the URL setting process.

Related to Review: https://github.com/jetkvm/kvm/pull/1312#pullrequestreview-3984856379

* refactor(tailscale): simplify control URL application logic and enhance error handling

- Renamed the test function to better reflect its purpose and updated the test cases to ensure correct command execution.
- Removed fallback logic for applying the Tailscale control URL, streamlining the error handling to return a clear error message when the "set" command fails.
- Added a new test to verify behavior when the "set" command fails, ensuring proper error reporting.

Related to Review https://github.com/jetkvm/kvm/pull/1312#pullrequestreview-3984856379

* refactor(tailscale): update control server configuration in UI & documentation

- Updated the TailscaleCard component to allow users to select between default and custom control server URLs.
- Improved state management for control server URL input based on the selected mode.
- Revised DEVELOPMENT.md to clarify control server application logic and error handling.
- Removed outdated example JSON-RPC payloads for clarity.

Related to Review: https://github.com/jetkvm/kvm/pull/1312#discussion_r2980284611

* refactor(ui): use built-in components and i18n for TailscaleCard

- Replace raw <select> with SelectMenuBasic component
- Use SettingsItem with new SM size for control server setting
- Use NestedSettingsGroup for indented custom URL input
- Add tailscale_* i18n keys to all 14 locale files with translations
- Add size prop (SM/MD) to SettingsItem for compact contexts

---------

Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
This commit is contained in:
Lukas Wolfsteiner
2026-03-24 15:20:15 +01:00
committed by GitHub
parent e7e1a289df
commit 519e391595
23 changed files with 813 additions and 81 deletions
+16
View File
@@ -144,6 +144,7 @@ tail -f /userdata/jetkvm/last.log
- `web.go` - Add new API endpoints here
- `config.go` - Add new settings here
- `tailscale.go` - Tailscale status and control-server logic
- `ui/src/routes/` - Add new pages here
- `ui/src/components/` - Add new UI components here
@@ -250,6 +251,21 @@ curl -X POST http://<IP>/auth/password-local \
-d '{"password": "test123"}'
```
### Tailscale control server testing
JetKVM exposes Tailscale control-server configuration through JSON-RPC so self-hosted control planes (for example Headscale) can be used.
- `getTailscaleStatus` returns current state and effective `controlURL`
- `getTailscaleControlURL` returns the effective control server URL
- `setTailscaleControlURL` updates and persists the URL (empty value resets to default)
Notes:
- URLs must be `http://` or `https://` and include a host.
- Query strings, fragments, user info, and non-root paths are rejected.
- Control server changes are applied with `tailscale set --login-server=...`.
- When apply fails, the previous configured URL is restored and not persisted.
### End to End Testing
+1
View File
@@ -17,6 +17,7 @@ JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) s
- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse and keyboard interaction for responsive remote control.
- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC.
- **Optional Tailscale Networking** - Built-in Tailscale status and control-server configuration, including custom [Headscale](https://headscale.net/)-compatible endpoints.
- **Open-source software** - Written in Golang on Linux. Easily customizable through SSH access to the JetKVM device.
## Contributing
+1
View File
@@ -89,6 +89,7 @@ type Config struct {
UpdateAPIURL string `json:"update_api_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"`
TailscaleControlURL string `json:"tailscale_control_url,omitempty"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
JigglerConfig *JigglerConfig `json:"jiggler_config"`
+2
View File
@@ -1214,6 +1214,8 @@ var rpcHandlers = map[string]RPCHandler{
"getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}},
"checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses},
"getTailscaleStatus": {Func: rpcGetTailscaleStatus},
"getTailscaleControlURL": {Func: rpcGetTailscaleControlURL},
"setTailscaleControlURL": {Func: rpcSetTailscaleControlURL, Params: []string{"controlURL"}},
"getMqttSettings": {Func: rpcGetMqttSettings},
"setMqttSettings": {Func: rpcSetMqttSettings, Params: []string{"settings"}},
"getMqttStatus": {Func: rpcGetMqttStatus},
+125 -15
View File
@@ -3,7 +3,9 @@ package kvm
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os/exec"
"strings"
"time"
@@ -11,12 +13,15 @@ import (
const tailscaleCommandTimeout = 10 * time.Second
const tailscaleDefaultControlURL = "https://controlplane.tailscale.com"
// TailscaleStatus represents the current state of Tailscale on the device.
type TailscaleStatus struct {
Installed bool `json:"installed"`
Running bool `json:"running"`
BackendState string `json:"backendState,omitempty"`
AuthURL string `json:"authURL,omitempty"`
ControlURL string `json:"controlURL,omitempty"`
Self *TailscalePeer `json:"self,omitempty"`
Health []string `json:"health,omitempty"`
}
@@ -52,41 +57,108 @@ func isTailscaleInstalled() bool {
return err == nil
}
// execTailscaleStatus runs `tailscale status --json` and returns the raw output.
// This is a package-level var to allow test substitution.
var execTailscaleStatus = func() ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), tailscaleCommandTimeout)
defer cancel()
// These package-level vars allow deterministic unit tests.
var (
checkTailscaleInstalled = isTailscaleInstalled
saveTailscaleConfig = SaveConfig
execTailscaleCommand = func(args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), tailscaleCommandTimeout)
defer cancel()
output, err := exec.CommandContext(ctx, "tailscale", "status", "--json").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("tailscale status: %w: %s", err, strings.TrimSpace(string(output)))
output, err := exec.CommandContext(ctx, "tailscale", args...).CombinedOutput()
if err != nil {
cmd := "tailscale " + strings.Join(args, " ")
return nil, fmt.Errorf("%s: %w: %s", cmd, err, strings.TrimSpace(string(output)))
}
return output, nil
}
)
// execTailscaleStatus runs `tailscale status --json` and returns the raw output.
func execTailscaleStatus() ([]byte, error) {
return execTailscaleCommand("status", "--json")
}
func normalizeTailscaleControlURL(controlURL string) (string, error) {
trimmed := strings.TrimSpace(controlURL)
if trimmed == "" {
return "", nil
}
return output, nil
parsed, err := url.Parse(trimmed)
if err != nil {
return "", fmt.Errorf("invalid control URL: %w", err)
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return "", errors.New("control URL must start with http:// or https://")
}
if parsed.Host == "" {
return "", errors.New("control URL must include a host")
}
if parsed.User != nil {
return "", errors.New("control URL must not include user info")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("control URL must not include query or fragment")
}
if parsed.Path != "" && parsed.Path != "/" {
return "", errors.New("control URL path is not supported")
}
parsed.Path = ""
parsed.RawPath = ""
return strings.TrimSuffix(parsed.String(), "/"), nil
}
func effectiveTailscaleControlURL(controlURL string) string {
if controlURL == "" {
return tailscaleDefaultControlURL
}
return controlURL
}
func applyTailscaleControlURL(controlURL string) error {
effectiveURL := effectiveTailscaleControlURL(controlURL)
loginServerFlag := "--login-server=" + effectiveURL
if _, err := execTailscaleCommand("set", loginServerFlag); err != nil {
return fmt.Errorf("failed to apply login server (%s): %w", effectiveURL, err)
}
return nil
}
// getTailscaleStatus queries the Tailscale daemon for current status.
// Returns a TailscaleStatus with Installed=false when the binary is not found.
func getTailscaleStatus() (*TailscaleStatus, error) {
if !isTailscaleInstalled() {
return &TailscaleStatus{Installed: false}, nil
ensureConfigLoaded()
controlURL := config.TailscaleControlURL
if !checkTailscaleInstalled() {
return &TailscaleStatus{
Installed: false,
ControlURL: effectiveTailscaleControlURL(controlURL),
}, nil
}
output, err := execTailscaleStatus()
if err != nil {
tailscaleLogger.Warn().Err(err).Msg("failed to get tailscale status")
return &TailscaleStatus{
Installed: true,
Running: false,
Installed: true,
Running: false,
ControlURL: effectiveTailscaleControlURL(controlURL),
}, nil
}
return parseTailscaleStatus(output)
return parseTailscaleStatus(output, controlURL)
}
// parseTailscaleStatus parses the JSON output from `tailscale status --json`.
func parseTailscaleStatus(data []byte) (*TailscaleStatus, error) {
func parseTailscaleStatus(data []byte, controlURL string) (*TailscaleStatus, error) {
var raw tailscaleRawStatus
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("failed to parse tailscale status: %w", err)
@@ -97,6 +169,7 @@ func parseTailscaleStatus(data []byte) (*TailscaleStatus, error) {
Running: raw.BackendState == "Running",
BackendState: raw.BackendState,
AuthURL: raw.AuthURL,
ControlURL: effectiveTailscaleControlURL(controlURL),
Health: raw.Health,
}
@@ -116,3 +189,40 @@ func parseTailscaleStatus(data []byte) (*TailscaleStatus, error) {
func rpcGetTailscaleStatus() (*TailscaleStatus, error) {
return getTailscaleStatus()
}
func rpcGetTailscaleControlURL() (string, error) {
ensureConfigLoaded()
return effectiveTailscaleControlURL(config.TailscaleControlURL), nil
}
func rpcSetTailscaleControlURL(controlURL string) error {
ensureConfigLoaded()
normalizedURL, err := normalizeTailscaleControlURL(controlURL)
if err != nil {
return err
}
previousURL := config.TailscaleControlURL
config.TailscaleControlURL = normalizedURL
if !checkTailscaleInstalled() {
if err := saveTailscaleConfig(); err != nil {
config.TailscaleControlURL = previousURL
return fmt.Errorf("failed to save tailscale control URL: %w", err)
}
return nil
}
if err := applyTailscaleControlURL(normalizedURL); err != nil {
config.TailscaleControlURL = previousURL
return err
}
if err := saveTailscaleConfig(); err != nil {
config.TailscaleControlURL = previousURL
return fmt.Errorf("failed to save tailscale control URL: %w", err)
}
return nil
}
+221 -38
View File
@@ -5,16 +5,19 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseTailscaleStatus(t *testing.T) {
tests := []struct {
name string
input string
controlURL string
wantErr bool
wantRunning bool
wantState string
wantAuthURL string
wantControl string
wantHostName string
wantIPs []string
}{
@@ -31,8 +34,10 @@ func TestParseTailscaleStatus(t *testing.T) {
},
"Health": []
}`,
controlURL: "https://headscale.example.com",
wantRunning: true,
wantState: "Running",
wantControl: "https://headscale.example.com",
wantHostName: "cortex-kvm",
wantIPs: []string{"100.80.194.50", "fd7a:115c:a1e0::1"},
},
@@ -44,9 +49,11 @@ func TestParseTailscaleStatus(t *testing.T) {
"Self": null,
"Health": []
}`,
controlURL: "",
wantRunning: false,
wantState: "NeedsLogin",
wantAuthURL: "https://login.tailscale.com/a/abc123",
wantControl: tailscaleDefaultControlURL,
},
{
name: "stopped",
@@ -55,8 +62,10 @@ func TestParseTailscaleStatus(t *testing.T) {
"Self": null,
"Health": []
}`,
controlURL: "https://headscale.example.com/",
wantRunning: false,
wantState: "Stopped",
wantControl: "https://headscale.example.com/",
},
{
name: "starting",
@@ -65,19 +74,24 @@ func TestParseTailscaleStatus(t *testing.T) {
"Self": null,
"Health": ["not yet connected"]
}`,
controlURL: "",
wantRunning: false,
wantState: "Starting",
wantControl: tailscaleDefaultControlURL,
},
{
name: "invalid json",
input: `{invalid`,
wantErr: true,
name: "invalid json",
input: `{invalid`,
controlURL: "",
wantErr: true,
},
{
name: "empty json",
input: `{}`,
controlURL: "https://headscale.example.com",
wantRunning: false,
wantState: "",
wantControl: "https://headscale.example.com",
},
{
name: "running without IPs",
@@ -91,8 +105,10 @@ func TestParseTailscaleStatus(t *testing.T) {
"OS": "linux"
}
}`,
controlURL: "",
wantRunning: true,
wantState: "Running",
wantControl: tailscaleDefaultControlURL,
wantHostName: "test-node",
wantIPs: []string{},
},
@@ -100,7 +116,7 @@ func TestParseTailscaleStatus(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status, err := parseTailscaleStatus([]byte(tt.input))
status, err := parseTailscaleStatus([]byte(tt.input), tt.controlURL)
if tt.wantErr {
assert.Error(t, err)
return
@@ -111,6 +127,7 @@ func TestParseTailscaleStatus(t *testing.T) {
assert.Equal(t, tt.wantRunning, status.Running)
assert.Equal(t, tt.wantState, status.BackendState)
assert.Equal(t, tt.wantAuthURL, status.AuthURL)
assert.Equal(t, tt.wantControl, status.ControlURL)
if tt.wantHostName != "" {
assert.NotNil(t, status.Self)
@@ -125,48 +142,95 @@ func TestParseTailscaleStatus(t *testing.T) {
}
}
func TestGetTailscaleStatus_NotInstalled(t *testing.T) {
// Save and restore the original exec function
origExec := execTailscaleStatus
defer func() { execTailscaleStatus = origExec }()
func TestNormalizeTailscaleControlURL(t *testing.T) {
t.Run("empty means default", func(t *testing.T) {
got, err := normalizeTailscaleControlURL("")
require.NoError(t, err)
assert.Equal(t, "", got)
})
// The function should never be called when tailscale is not in PATH.
// We can't easily mock exec.LookPath, but we can verify parseTailscaleStatus
// handles all the edge cases above. This test verifies the exec mock path.
execTailscaleStatus = func() ([]byte, error) {
return nil, fmt.Errorf("tailscale not running")
t.Run("valid URL trimmed", func(t *testing.T) {
got, err := normalizeTailscaleControlURL(" https://headscale.example.com/ ")
require.NoError(t, err)
assert.Equal(t, "https://headscale.example.com", got)
})
t.Run("rejects path", func(t *testing.T) {
_, err := normalizeTailscaleControlURL("https://headscale.example.com/api")
require.Error(t, err)
assert.Contains(t, err.Error(), "path")
})
t.Run("rejects invalid scheme", func(t *testing.T) {
_, err := normalizeTailscaleControlURL("ftp://headscale.example.com")
require.Error(t, err)
assert.Contains(t, err.Error(), "http:// or https://")
})
}
func TestGetTailscaleStatus_NotInstalled(t *testing.T) {
origCheck := checkTailscaleInstalled
origExec := execTailscaleCommand
origConfig := config
defer func() {
checkTailscaleInstalled = origCheck
execTailscaleCommand = origExec
config = origConfig
}()
config = &Config{TailscaleControlURL: "https://headscale.example.com"}
checkTailscaleInstalled = func() bool { return false }
execTailscaleCommand = func(_ ...string) ([]byte, error) {
return nil, fmt.Errorf("should not be called")
}
status, err := getTailscaleStatus()
// When tailscale is installed but daemon is down, we get installed=true, running=false
// When not installed, LookPath fails and we get installed=false
// Since we can't mock LookPath easily, just verify the error path through exec
assert.NoError(t, err)
assert.NotNil(t, status)
// Status depends on whether tailscale binary exists on the test machine
// The important thing is it never returns an error
require.NoError(t, err)
require.NotNil(t, status)
assert.False(t, status.Installed)
assert.False(t, status.Running)
assert.Equal(t, "https://headscale.example.com", status.ControlURL)
}
func TestGetTailscaleStatus_ExecFailure(t *testing.T) {
origExec := execTailscaleStatus
defer func() { execTailscaleStatus = origExec }()
origCheck := checkTailscaleInstalled
origExec := execTailscaleCommand
origConfig := config
defer func() {
checkTailscaleInstalled = origCheck
execTailscaleCommand = origExec
config = origConfig
}()
execTailscaleStatus = func() ([]byte, error) {
config = &Config{}
checkTailscaleInstalled = func() bool { return true }
execTailscaleCommand = func(args ...string) ([]byte, error) {
require.Equal(t, []string{"status", "--json"}, args)
return nil, fmt.Errorf("connection refused")
}
status, err := getTailscaleStatus()
assert.NoError(t, err)
assert.NotNil(t, status)
assert.True(t, status.Installed || !status.Installed) // depends on test env
require.NoError(t, err)
require.NotNil(t, status)
assert.True(t, status.Installed)
assert.False(t, status.Running)
assert.Equal(t, tailscaleDefaultControlURL, status.ControlURL)
}
func TestGetTailscaleStatus_ValidJSON(t *testing.T) {
origExec := execTailscaleStatus
defer func() { execTailscaleStatus = origExec }()
origCheck := checkTailscaleInstalled
origExec := execTailscaleCommand
origConfig := config
defer func() {
checkTailscaleInstalled = origCheck
execTailscaleCommand = origExec
config = origConfig
}()
execTailscaleStatus = func() ([]byte, error) {
config = &Config{TailscaleControlURL: "https://headscale.example.com"}
checkTailscaleInstalled = func() bool { return true }
execTailscaleCommand = func(args ...string) ([]byte, error) {
require.Equal(t, []string{"status", "--json"}, args)
return []byte(`{
"BackendState": "Running",
"Self": {
@@ -181,13 +245,132 @@ func TestGetTailscaleStatus_ValidJSON(t *testing.T) {
}
status, err := getTailscaleStatus()
assert.NoError(t, err)
assert.NotNil(t, status)
// If tailscale binary doesn't exist on test machine, we get installed=false
// and the exec mock is never called. Both paths are valid.
if status.Installed {
assert.True(t, status.Running)
assert.Equal(t, "test-kvm", status.Self.HostName)
assert.Equal(t, []string{"100.64.0.1"}, status.Self.TailscaleIPs)
}
require.NoError(t, err)
require.NotNil(t, status)
assert.True(t, status.Installed)
assert.True(t, status.Running)
assert.Equal(t, "https://headscale.example.com", status.ControlURL)
require.NotNil(t, status.Self)
assert.Equal(t, "test-kvm", status.Self.HostName)
assert.Equal(t, []string{"100.64.0.1"}, status.Self.TailscaleIPs)
}
func TestApplyTailscaleControlURL_SetOnly(t *testing.T) {
origExec := execTailscaleCommand
defer func() { execTailscaleCommand = origExec }()
var commands [][]string
execTailscaleCommand = func(args ...string) ([]byte, error) {
commands = append(commands, append([]string{}, args...))
return []byte("ok"), nil
}
err := applyTailscaleControlURL("https://headscale.example.com")
require.NoError(t, err)
require.Len(t, commands, 1)
assert.Equal(t, []string{"set", "--login-server=https://headscale.example.com"}, commands[0])
}
func TestApplyTailscaleControlURL_SetFailureReturnedWithoutFallback(t *testing.T) {
origExec := execTailscaleCommand
defer func() { execTailscaleCommand = origExec }()
var commands [][]string
execTailscaleCommand = func(args ...string) ([]byte, error) {
commands = append(commands, append([]string{}, args...))
return nil, fmt.Errorf("unknown command")
}
err := applyTailscaleControlURL("https://headscale.example.com")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to apply login server")
require.Len(t, commands, 1)
assert.Equal(t, []string{"set", "--login-server=https://headscale.example.com"}, commands[0])
}
func TestRPCSetTailscaleControlURL_SaveAndApply(t *testing.T) {
origCheck := checkTailscaleInstalled
origExec := execTailscaleCommand
origSave := saveTailscaleConfig
origConfig := config
defer func() {
checkTailscaleInstalled = origCheck
execTailscaleCommand = origExec
saveTailscaleConfig = origSave
config = origConfig
}()
config = &Config{}
checkTailscaleInstalled = func() bool { return true }
var callOrder []string
saveTailscaleConfig = func() error {
callOrder = append(callOrder, "save")
return nil
}
var commands [][]string
execTailscaleCommand = func(args ...string) ([]byte, error) {
callOrder = append(callOrder, "apply")
commands = append(commands, append([]string{}, args...))
return []byte("ok"), nil
}
err := rpcSetTailscaleControlURL("https://headscale.example.com/")
require.NoError(t, err)
assert.Equal(t, []string{"apply", "save"}, callOrder)
assert.Equal(t, "https://headscale.example.com", config.TailscaleControlURL)
require.Len(t, commands, 1)
assert.Equal(t, []string{"set", "--login-server=https://headscale.example.com"}, commands[0])
}
func TestRPCSetTailscaleControlURL_ApplyFailureDoesNotSaveOrPersistConfig(t *testing.T) {
origCheck := checkTailscaleInstalled
origExec := execTailscaleCommand
origSave := saveTailscaleConfig
origConfig := config
defer func() {
checkTailscaleInstalled = origCheck
execTailscaleCommand = origExec
saveTailscaleConfig = origSave
config = origConfig
}()
config = &Config{TailscaleControlURL: "https://previous.example.com"}
checkTailscaleInstalled = func() bool { return true }
saveTailscaleConfig = func() error {
t.Fatal("save should not be called when apply fails")
return nil
}
execTailscaleCommand = func(args ...string) ([]byte, error) {
require.Equal(t, []string{"set", "--login-server=https://headscale.example.com"}, args)
return nil, fmt.Errorf("apply failed")
}
err := rpcSetTailscaleControlURL("https://headscale.example.com")
require.Error(t, err)
assert.Equal(t, "https://previous.example.com", config.TailscaleControlURL)
}
func TestRPCSetTailscaleControlURL_NotInstalledSkipsApply(t *testing.T) {
origCheck := checkTailscaleInstalled
origExec := execTailscaleCommand
origSave := saveTailscaleConfig
origConfig := config
defer func() {
checkTailscaleInstalled = origCheck
execTailscaleCommand = origExec
saveTailscaleConfig = origSave
config = origConfig
}()
config = &Config{}
checkTailscaleInstalled = func() bool { return false }
saveTailscaleConfig = func() error { return nil }
execTailscaleCommand = func(_ ...string) ([]byte, error) {
return nil, fmt.Errorf("should not be called")
}
err := rpcSetTailscaleControlURL("")
require.NoError(t, err)
assert.Equal(t, "", config.TailscaleControlURL)
}
+22
View File
@@ -812,6 +812,28 @@
"something_went_wrong": "Aeth rhywbeth o'i le. Rhowch gynnig arall arni yn nes ymlaen neu cysylltwch â chymorth",
"step_counter_step": "Cam {step}",
"subnet_mask": "Mwgwd Is-rwydwaith",
"tailscale_auth_description": "Mae Tailscale angen dilysu. Agorwch y ddolen isod i fewngofnodi.",
"tailscale_connected": "Wedi cysylltu",
"tailscale_control_server_custom": "Personol",
"tailscale_control_server_custom_url_label": "URL Gweinydd Rheoli Personol",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "Rhagosodiad",
"tailscale_control_server_description": "Ffurfweddwch bwynt terfyn plân rheoli Tailscale",
"tailscale_control_server_title": "Gweinydd Rheoli",
"tailscale_control_server_update_failed": "Methwyd â diweddaru gweinydd rheoli Tailscale: {error}",
"tailscale_control_server_update_success": "Gweinydd rheoli Tailscale wedi'i ddiweddaru",
"tailscale_dns_name": "Enw DNS",
"tailscale_hostname": "Enw Gwesteiwr",
"tailscale_installed_not_running": "Mae Tailscale wedi'i osod ond nid yw'n rhedeg.",
"tailscale_installed_not_running_state": " Cyflwr: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Angen Mewngofnodi",
"tailscale_refresh": "Adnewyddu",
"tailscale_save": "Cadw",
"tailscale_saving": "Yn cadw…",
"tailscale_stopped": "Wedi stopio",
"tailscale_title": "Tailscale",
"time_division_days": "diwrnodau",
"time_division_hours": "oriau",
"time_division_minutes": "munudau",
+23 -1
View File
@@ -94,10 +94,10 @@
"advanced_ssh_access_description": "Tilføj din offentlige SSH-nøgle for at aktivere sikker fjernadgang til enheden",
"advanced_ssh_access_title": "SSH-adgang",
"advanced_ssh_default_user": "Standard SSH-brugeren er",
"advanced_ssh_key_required_warning": "En offentlig nøgle er påkrævet for SSH-adgang. Uden en vil du ikke kunne oprette forbindelse.",
"advanced_ssh_public_key_label": "Offentlig SSH-nøgle",
"advanced_ssh_public_key_placeholder": "Indtast din offentlige SSH-nøgle",
"advanced_success_download_diagnostics": "Diagnostik blev downloadet",
"advanced_ssh_key_required_warning": "En offentlig nøgle er påkrævet for SSH-adgang. Uden en vil du ikke kunne oprette forbindelse.",
"advanced_success_loopback_disabled": "Kun loopback-tilstand er deaktiveret. Genstart din enhed for at anvende den.",
"advanced_success_loopback_enabled": "Kun loopback-tilstand aktiveret. Genstart din enhed for at anvende den.",
"advanced_success_reset_config": "Konfigurationen er nulstillet til standard",
@@ -826,6 +826,28 @@
"something_went_wrong": "Noget gik galt. Prøv igen senere, eller kontakt support.",
"step_counter_step": "Trin {step}",
"subnet_mask": "Undernetmaske",
"tailscale_auth_description": "Tailscale kræver godkendelse. Åbn linket nedenfor for at logge ind.",
"tailscale_connected": "Forbundet",
"tailscale_control_server_custom": "Tilpasset",
"tailscale_control_server_custom_url_label": "Tilpasset kontrolserver-URL",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "Standard",
"tailscale_control_server_description": "Konfigurer Tailscale-kontrolplanets slutpunkt",
"tailscale_control_server_title": "Kontrolserver",
"tailscale_control_server_update_failed": "Kunne ikke opdatere Tailscale-kontrolserveren: {error}",
"tailscale_control_server_update_success": "Tailscale-kontrolserver opdateret",
"tailscale_dns_name": "DNS-navn",
"tailscale_hostname": "Værtsnavn",
"tailscale_installed_not_running": "Tailscale er installeret, men kører ikke.",
"tailscale_installed_not_running_state": " Tilstand: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Login påkrævet",
"tailscale_refresh": "Opdater",
"tailscale_save": "Gem",
"tailscale_saving": "Gemmer…",
"tailscale_stopped": "Stoppet",
"tailscale_title": "Tailscale",
"time_division_days": "dage",
"time_division_hours": "timer",
"time_division_minutes": "minutter",
+23 -1
View File
@@ -94,10 +94,10 @@
"advanced_ssh_access_description": "Fügen Sie Ihren öffentlichen SSH-Schlüssel hinzu, um einen sicheren Fernzugriff auf das Gerät zu ermöglichen",
"advanced_ssh_access_title": "SSH-Zugriff",
"advanced_ssh_default_user": "Der Standard-SSH-Benutzer ist",
"advanced_ssh_key_required_warning": "Ein öffentlicher Schlüssel ist für den SSH-Zugang erforderlich. Ohne diesen können Sie keine Verbindung herstellen.",
"advanced_ssh_public_key_label": "Öffentlicher SSH-Schlüssel",
"advanced_ssh_public_key_placeholder": "Geben Sie Ihren öffentlichen SSH-Schlüssel ein",
"advanced_success_download_diagnostics": "Diagnosedaten erfolgreich heruntergeladen",
"advanced_ssh_key_required_warning": "Ein öffentlicher Schlüssel ist für den SSH-Zugang erforderlich. Ohne diesen können Sie keine Verbindung herstellen.",
"advanced_success_loopback_disabled": "Nur-Loopback-Modus deaktiviert. Starten Sie Ihr Gerät neu, um die Funktion anzuwenden.",
"advanced_success_loopback_enabled": "Nur-Loopback-Modus aktiviert. Starten Sie Ihr Gerät neu, um die Funktion anzuwenden.",
"advanced_success_reset_config": "Konfiguration erfolgreich auf Standard zurückgesetzt",
@@ -826,6 +826,28 @@
"something_went_wrong": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es später noch einmal oder wenden Sie sich an den Support.",
"step_counter_step": "Schritt {step}",
"subnet_mask": "Subnetzmaske",
"tailscale_auth_description": "Tailscale erfordert eine Authentifizierung. Öffnen Sie den untenstehenden Link, um sich anzumelden.",
"tailscale_connected": "Verbunden",
"tailscale_control_server_custom": "Benutzerdefiniert",
"tailscale_control_server_custom_url_label": "Benutzerdefinierte Control-Server-URL",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "Standard",
"tailscale_control_server_description": "Konfigurieren Sie den Tailscale-Control-Plane-Endpunkt",
"tailscale_control_server_title": "Control-Server",
"tailscale_control_server_update_failed": "Tailscale-Control-Server konnte nicht aktualisiert werden: {error}",
"tailscale_control_server_update_success": "Tailscale-Control-Server aktualisiert",
"tailscale_dns_name": "DNS-Name",
"tailscale_hostname": "Hostname",
"tailscale_installed_not_running": "Tailscale ist installiert, wird aber nicht ausgeführt.",
"tailscale_installed_not_running_state": " Status: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Anmeldung erforderlich",
"tailscale_refresh": "Aktualisieren",
"tailscale_save": "Speichern",
"tailscale_saving": "Wird gespeichert…",
"tailscale_stopped": "Gestoppt",
"tailscale_title": "Tailscale",
"time_division_days": "Tage",
"time_division_hours": "Std.",
"time_division_minutes": "Minuten",
+23 -1
View File
@@ -94,9 +94,9 @@
"advanced_ssh_access_description": "Add your SSH public key to enable secure remote access to the device",
"advanced_ssh_access_title": "SSH Access",
"advanced_ssh_default_user": "The default SSH user is",
"advanced_ssh_key_required_warning": "A public key is required for SSH access. Without one, you will not be able to connect.",
"advanced_ssh_public_key_label": "SSH Public Key",
"advanced_ssh_public_key_placeholder": "Enter your SSH public key",
"advanced_ssh_key_required_warning": "A public key is required for SSH access. Without one, you will not be able to connect.",
"advanced_success_download_diagnostics": "Diagnostics downloaded successfully",
"advanced_success_loopback_disabled": "Loopback-only mode disabled. Restart your device to apply.",
"advanced_success_loopback_enabled": "Loopback-only mode enabled. Restart your device to apply.",
@@ -877,6 +877,28 @@
"something_went_wrong": "Something went wrong. Please try again later or contact support",
"step_counter_step": "Step {step}",
"subnet_mask": "Subnet Mask",
"tailscale_auth_description": "Tailscale requires authentication. Open the link below to log in.",
"tailscale_connected": "Connected",
"tailscale_control_server_custom": "Custom",
"tailscale_control_server_custom_url_label": "Custom Control Server URL",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "Default",
"tailscale_control_server_description": "Configure the Tailscale control plane endpoint",
"tailscale_control_server_title": "Control Server",
"tailscale_control_server_update_failed": "Failed to update Tailscale control server: {error}",
"tailscale_control_server_update_success": "Tailscale control server updated",
"tailscale_dns_name": "DNS Name",
"tailscale_hostname": "Hostname",
"tailscale_installed_not_running": "Tailscale is installed but not running.",
"tailscale_installed_not_running_state": " State: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Needs Login",
"tailscale_refresh": "Refresh",
"tailscale_save": "Save",
"tailscale_saving": "Saving...",
"tailscale_stopped": "Stopped",
"tailscale_title": "Tailscale",
"time_division_days": "days",
"time_division_hours": "hours",
"time_division_minutes": "minutes",
+23 -1
View File
@@ -94,10 +94,10 @@
"advanced_ssh_access_description": "Agregue su clave pública SSH para habilitar el acceso remoto seguro al dispositivo",
"advanced_ssh_access_title": "Acceso SSH",
"advanced_ssh_default_user": "El usuario SSH predeterminado es",
"advanced_ssh_key_required_warning": "Se requiere una clave pública para el acceso SSH. Sin ella, no podrá conectarse.",
"advanced_ssh_public_key_label": "Clave pública SSH",
"advanced_ssh_public_key_placeholder": "Ingrese su clave pública SSH",
"advanced_success_download_diagnostics": "Diagnóstico descargado exitosamente",
"advanced_ssh_key_required_warning": "Se requiere una clave pública para el acceso SSH. Sin ella, no podrá conectarse.",
"advanced_success_loopback_disabled": "El modo de solo bucle invertido está deshabilitado. Reinicie el dispositivo para aplicarlo.",
"advanced_success_loopback_enabled": "Modo de solo bucle invertido habilitado. Reinicie el dispositivo para aplicarlo.",
"advanced_success_reset_config": "La configuración se restableció a los valores predeterminados correctamente",
@@ -826,6 +826,28 @@
"something_went_wrong": "Algo salió mal. Inténtalo de nuevo más tarde o contacta con el servicio de asistencia.",
"step_counter_step": "Paso {step}",
"subnet_mask": "Máscara de subred",
"tailscale_auth_description": "Tailscale requiere autenticación. Abra el enlace de abajo para iniciar sesión.",
"tailscale_connected": "Conectado",
"tailscale_control_server_custom": "Personalizado",
"tailscale_control_server_custom_url_label": "URL del servidor de control personalizado",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "Predeterminado",
"tailscale_control_server_description": "Configurar el punto de conexión del plano de control de Tailscale",
"tailscale_control_server_title": "Servidor de control",
"tailscale_control_server_update_failed": "No se pudo actualizar el servidor de control de Tailscale: {error}",
"tailscale_control_server_update_success": "Servidor de control de Tailscale actualizado",
"tailscale_dns_name": "Nombre DNS",
"tailscale_hostname": "Nombre de host",
"tailscale_installed_not_running": "Tailscale está instalado pero no se está ejecutando.",
"tailscale_installed_not_running_state": " Estado: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Inicio de sesión necesario",
"tailscale_refresh": "Actualizar",
"tailscale_save": "Guardar",
"tailscale_saving": "Guardando…",
"tailscale_stopped": "Detenido",
"tailscale_title": "Tailscale",
"time_division_days": "días",
"time_division_hours": "horas",
"time_division_minutes": "minutos",
+23 -1
View File
@@ -94,10 +94,10 @@
"advanced_ssh_access_description": "Ajoutez votre clé publique SSH pour activer l'accès à distance sécurisé à l'appareil",
"advanced_ssh_access_title": "Accès SSH",
"advanced_ssh_default_user": "L'utilisateur SSH par défaut est",
"advanced_ssh_key_required_warning": "Une clé publique est requise pour l'accès SSH. Sans celle-ci, vous ne pourrez pas vous connecter.",
"advanced_ssh_public_key_label": "Clé publique SSH",
"advanced_ssh_public_key_placeholder": "Entrez votre clé publique SSH",
"advanced_success_download_diagnostics": "Diagnostics téléchargés avec succès",
"advanced_ssh_key_required_warning": "Une clé publique est requise pour l'accès SSH. Sans celle-ci, vous ne pourrez pas vous connecter.",
"advanced_success_loopback_disabled": "Mode de bouclage désactivé. Redémarrez votre appareil pour appliquer le mode de bouclage.",
"advanced_success_loopback_enabled": "Mode de bouclage activé. Redémarrez votre appareil pour appliquer la fonction.",
"advanced_success_reset_config": "La configuration par défaut a été réinitialisée avec succès",
@@ -826,6 +826,28 @@
"something_went_wrong": "Une erreur s'est produite. Veuillez réessayer ultérieurement ou contacter le support.",
"step_counter_step": "Étape {step}",
"subnet_mask": "Masque de sous-réseau",
"tailscale_auth_description": "Tailscale nécessite une authentification. Ouvrez le lien ci-dessous pour vous connecter.",
"tailscale_connected": "Connecté",
"tailscale_control_server_custom": "Personnalisé",
"tailscale_control_server_custom_url_label": "URL du serveur de contrôle personnalisé",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "Par défaut",
"tailscale_control_server_description": "Configurer le point de terminaison du plan de contrôle Tailscale",
"tailscale_control_server_title": "Serveur de contrôle",
"tailscale_control_server_update_failed": "Échec de la mise à jour du serveur de contrôle Tailscale : {error}",
"tailscale_control_server_update_success": "Serveur de contrôle Tailscale mis à jour",
"tailscale_dns_name": "Nom DNS",
"tailscale_hostname": "Nom d'hôte",
"tailscale_installed_not_running": "Tailscale est installé mais ne fonctionne pas.",
"tailscale_installed_not_running_state": " État : {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Connexion requise",
"tailscale_refresh": "Rafraîchir",
"tailscale_save": "Enregistrer",
"tailscale_saving": "Enregistrement…",
"tailscale_stopped": "Arrêté",
"tailscale_title": "Tailscale",
"time_division_days": "jours",
"time_division_hours": "heures",
"time_division_minutes": "minutes",
+23 -1
View File
@@ -94,10 +94,10 @@
"advanced_ssh_access_description": "Aggiungi la tua chiave pubblica SSH per abilitare l'accesso remoto sicuro al dispositivo",
"advanced_ssh_access_title": "Accesso SSH",
"advanced_ssh_default_user": "L'utente SSH predefinito è",
"advanced_ssh_key_required_warning": "Una chiave pubblica è necessaria per l'accesso SSH. Senza di essa, non sarà possibile connettersi.",
"advanced_ssh_public_key_label": "Chiave pubblica SSH",
"advanced_ssh_public_key_placeholder": "Inserisci la tua chiave pubblica SSH",
"advanced_success_download_diagnostics": "Diagnostica scaricata correttamente",
"advanced_ssh_key_required_warning": "Una chiave pubblica è necessaria per l'accesso SSH. Senza di essa, non sarà possibile connettersi.",
"advanced_success_loopback_disabled": "Modalità loopback-only disattivata. Riavvia il dispositivo per applicare la modifica.",
"advanced_success_loopback_enabled": "Modalità loopback abilitata. Riavvia il dispositivo per applicare la modifica.",
"advanced_success_reset_config": "Configurazione ripristinata ai valori predefiniti con successo",
@@ -826,6 +826,28 @@
"something_went_wrong": "Qualcosa è andato storto. Riprova più tardi o contatta l'assistenza.",
"step_counter_step": "Passaggio {step}",
"subnet_mask": "Maschera di sottorete",
"tailscale_auth_description": "Tailscale richiede l'autenticazione. Apri il link sottostante per accedere.",
"tailscale_connected": "Collegato",
"tailscale_control_server_custom": "Personalizzato",
"tailscale_control_server_custom_url_label": "URL del server di controllo personalizzato",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "Predefinito",
"tailscale_control_server_description": "Configura l'endpoint del piano di controllo Tailscale",
"tailscale_control_server_title": "Server di controllo",
"tailscale_control_server_update_failed": "Impossibile aggiornare il server di controllo Tailscale: {error}",
"tailscale_control_server_update_success": "Server di controllo Tailscale aggiornato",
"tailscale_dns_name": "Nome DNS",
"tailscale_hostname": "Nome host",
"tailscale_installed_not_running": "Tailscale è installato ma non è in esecuzione.",
"tailscale_installed_not_running_state": " Stato: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Accesso necessario",
"tailscale_refresh": "Aggiorna",
"tailscale_save": "Salva",
"tailscale_saving": "Salvataggio in corso…",
"tailscale_stopped": "Arrestato",
"tailscale_title": "Tailscale",
"time_division_days": "giorni",
"time_division_hours": "ore",
"time_division_minutes": "minuti",
+23 -1
View File
@@ -94,9 +94,9 @@
"advanced_ssh_access_description": "SSH公開鍵を追加して、デバイスへの安全なリモートアクセスを有効にします",
"advanced_ssh_access_title": "SSHアクセス",
"advanced_ssh_default_user": "デフォルトのSSHユーザーは",
"advanced_ssh_key_required_warning": "SSHアクセスには公開鍵が必要です。公開鍵がないと接続できません。",
"advanced_ssh_public_key_label": "SSH公開鍵",
"advanced_ssh_public_key_placeholder": "SSH公開鍵を入力してください",
"advanced_ssh_key_required_warning": "SSHアクセスには公開鍵が必要です。公開鍵がないと接続できません。",
"advanced_success_download_diagnostics": "診断データが正常にダウンロードされました",
"advanced_success_loopback_disabled": "ループバック専用モードが無効になりました。適用するにはデバイスを再起動してください。",
"advanced_success_loopback_enabled": "ループバック専用モードが有効になりました。適用するにはデバイスを再起動してください。",
@@ -826,6 +826,28 @@
"something_went_wrong": "問題が発生しました。後でもう一度試すか、サポートにお問い合わせください",
"step_counter_step": "ステップ {step}",
"subnet_mask": "サブネットマスク",
"tailscale_auth_description": "Tailscale には認証が必要です。以下のリンクを開いてログインしてください。",
"tailscale_connected": "接続済み",
"tailscale_control_server_custom": "カスタム",
"tailscale_control_server_custom_url_label": "カスタムコントロールサーバーURL",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "デフォルト",
"tailscale_control_server_description": "Tailscale コントロールプレーンのエンドポイントを設定します",
"tailscale_control_server_title": "コントロールサーバー",
"tailscale_control_server_update_failed": "Tailscale コントロールサーバーの更新に失敗しました: {error}",
"tailscale_control_server_update_success": "Tailscale コントロールサーバーが更新されました",
"tailscale_dns_name": "DNS名",
"tailscale_hostname": "ホスト名",
"tailscale_installed_not_running": "Tailscale はインストールされていますが、実行されていません。",
"tailscale_installed_not_running_state": " 状態: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "ログインが必要",
"tailscale_refresh": "更新",
"tailscale_save": "保存",
"tailscale_saving": "保存中…",
"tailscale_stopped": "停止中",
"tailscale_title": "Tailscale",
"time_division_days": "日",
"time_division_hours": "時間",
"time_division_minutes": "分",
+23 -1
View File
@@ -94,10 +94,10 @@
"advanced_ssh_access_description": "Legg til din offentlige SSH-nøkkel for å aktivere sikker ekstern tilgang til enheten",
"advanced_ssh_access_title": "SSH-tilgang",
"advanced_ssh_default_user": "Standard SSH-brukeren er",
"advanced_ssh_key_required_warning": "En offentlig nøkkel er påkrevd for SSH-tilgang. Uten en vil du ikke kunne koble til.",
"advanced_ssh_public_key_label": "SSH offentlig nøkkel",
"advanced_ssh_public_key_placeholder": "Skriv inn din offentlige SSH-nøkkel",
"advanced_success_download_diagnostics": "Diagnostikken er lastet ned",
"advanced_ssh_key_required_warning": "En offentlig nøkkel er påkrevd for SSH-tilgang. Uten en vil du ikke kunne koble til.",
"advanced_success_loopback_disabled": "Kun tilbakekoblingsmodus deaktivert. Start enheten på nytt for å bruke den.",
"advanced_success_loopback_enabled": "Kun tilbakekoblingsmodus aktivert. Start enheten på nytt for å bruke den.",
"advanced_success_reset_config": "Konfigurasjonen ble tilbakestilt til standard",
@@ -826,6 +826,28 @@
"something_went_wrong": "Noe gikk galt. Prøv igjen senere eller kontakt kundestøtte.",
"step_counter_step": "Trinn {step}",
"subnet_mask": "Nettmaske",
"tailscale_auth_description": "Tailscale krever autentisering. Åpne lenken nedenfor for å logge inn.",
"tailscale_connected": "Tilkoblet",
"tailscale_control_server_custom": "Tilpasset",
"tailscale_control_server_custom_url_label": "Tilpasset kontrollserver-URL",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "Standard",
"tailscale_control_server_description": "Konfigurer Tailscale-kontrollplanets endepunkt",
"tailscale_control_server_title": "Kontrollserver",
"tailscale_control_server_update_failed": "Kunne ikke oppdatere Tailscale-kontrollserveren: {error}",
"tailscale_control_server_update_success": "Tailscale-kontrollserver oppdatert",
"tailscale_dns_name": "DNS-navn",
"tailscale_hostname": "Vertsnavn",
"tailscale_installed_not_running": "Tailscale er installert, men kjører ikke.",
"tailscale_installed_not_running_state": " Tilstand: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Innlogging nødvendig",
"tailscale_refresh": "Oppdater",
"tailscale_save": "Lagre",
"tailscale_saving": "Lagrer…",
"tailscale_stopped": "Stoppet",
"tailscale_title": "Tailscale",
"time_division_days": "dager",
"time_division_hours": "timer",
"time_division_minutes": "minutter",
+23 -1
View File
@@ -94,9 +94,9 @@
"advanced_ssh_access_description": "Adicione sua chave pública SSH para habilitar acesso remoto seguro ao dispositivo",
"advanced_ssh_access_title": "Acesso SSH",
"advanced_ssh_default_user": "O usuário SSH padrão é",
"advanced_ssh_key_required_warning": "Uma chave pública é necessária para acesso SSH. Sem ela, você não conseguirá se conectar.",
"advanced_ssh_public_key_label": "Chave Pública SSH",
"advanced_ssh_public_key_placeholder": "Digite sua chave pública SSH",
"advanced_ssh_key_required_warning": "Uma chave pública é necessária para acesso SSH. Sem ela, você não conseguirá se conectar.",
"advanced_success_download_diagnostics": "Diagnósticos baixados com sucesso",
"advanced_success_loopback_disabled": "Modo somente loopback desativado. Reinicie seu dispositivo para aplicar.",
"advanced_success_loopback_enabled": "Modo somente loopback ativado. Reinicie seu dispositivo para aplicar.",
@@ -826,6 +826,28 @@
"something_went_wrong": "Algo deu errado. Tente novamente mais tarde ou entre em contato com o suporte",
"step_counter_step": "Etapa {step}",
"subnet_mask": "Máscara de Sub-rede",
"tailscale_auth_description": "Tailscale requer autenticação. Abra o link abaixo para fazer login.",
"tailscale_connected": "Conectado",
"tailscale_control_server_custom": "Personalizado",
"tailscale_control_server_custom_url_label": "URL do Servidor de Controle Personalizado",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "Padrão",
"tailscale_control_server_description": "Configurar o endpoint do plano de controle do Tailscale",
"tailscale_control_server_title": "Servidor de Controle",
"tailscale_control_server_update_failed": "Falha ao atualizar o servidor de controle do Tailscale: {error}",
"tailscale_control_server_update_success": "Servidor de controle do Tailscale atualizado",
"tailscale_dns_name": "Nome DNS",
"tailscale_hostname": "Nome do Host",
"tailscale_installed_not_running": "Tailscale está instalado mas não está em execução.",
"tailscale_installed_not_running_state": " Estado: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Login Necessário",
"tailscale_refresh": "Atualizar",
"tailscale_save": "Salvar",
"tailscale_saving": "Salvando…",
"tailscale_stopped": "Parado",
"tailscale_title": "Tailscale",
"time_division_days": "dias",
"time_division_hours": "horas",
"time_division_minutes": "minutos",
+23 -1
View File
@@ -93,9 +93,9 @@
"advanced_ssh_access_description": "Добавьте ваш публичный SSH-ключ для безопасного удалённого доступа к устройству",
"advanced_ssh_access_title": "SSH-доступ",
"advanced_ssh_default_user": "Пользователь по умолчанию для SSH:",
"advanced_ssh_key_required_warning": "Для SSH-доступа необходим публичный ключ. Без него подключение будет невозможно.",
"advanced_ssh_public_key_label": "Публичный SSH-ключ",
"advanced_ssh_public_key_placeholder": "Введите ваш публичный SSH-ключ",
"advanced_ssh_key_required_warning": "Для SSH-доступа необходим публичный ключ. Без него подключение будет невозможно.",
"advanced_success_download_diagnostics": "Диагностика успешно скачана",
"advanced_success_loopback_disabled": "Режим только loopback отключён. Перезапустите устройство для применения.",
"advanced_success_loopback_enabled": "Режим только loopback включён. Перезапустите устройство для применения.",
@@ -814,6 +814,28 @@
"something_went_wrong": "Что-то пошло не так. Пожалуйста, попробуйте позже или свяжитесь с поддержкой",
"step_counter_step": "Шаг {step}",
"subnet_mask": "Маска подсети",
"tailscale_auth_description": "Tailscale требует аутентификации. Откройте ссылку ниже, чтобы войти.",
"tailscale_connected": "Подключено",
"tailscale_control_server_custom": "Пользовательский",
"tailscale_control_server_custom_url_label": "URL пользовательского сервера управления",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "По умолчанию",
"tailscale_control_server_description": "Настроить конечную точку плоскости управления Tailscale",
"tailscale_control_server_title": "Сервер управления",
"tailscale_control_server_update_failed": "Не удалось обновить сервер управления Tailscale: {error}",
"tailscale_control_server_update_success": "Сервер управления Tailscale обновлён",
"tailscale_dns_name": "DNS-имя",
"tailscale_hostname": "Имя хоста",
"tailscale_installed_not_running": "Tailscale установлен, но не запущен.",
"tailscale_installed_not_running_state": " Состояние: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Требуется вход",
"tailscale_refresh": "Обновить",
"tailscale_save": "Сохранить",
"tailscale_saving": "Сохранение…",
"tailscale_stopped": "Остановлен",
"tailscale_title": "Tailscale",
"time_division_days": "дней",
"time_division_hours": "часов",
"time_division_minutes": "минут",
+23 -1
View File
@@ -94,10 +94,10 @@
"advanced_ssh_access_description": "Lägg till din offentliga SSH-nyckel för att aktivera säker fjärråtkomst till enheten",
"advanced_ssh_access_title": "SSH-åtkomst",
"advanced_ssh_default_user": "Standard SSH-användaren är",
"advanced_ssh_key_required_warning": "En offentlig nyckel krävs för SSH-åtkomst. Utan en kommer du inte att kunna ansluta.",
"advanced_ssh_public_key_label": "SSH-publik nyckel",
"advanced_ssh_public_key_placeholder": "Ange din offentliga SSH-nyckel",
"advanced_success_download_diagnostics": "Diagnostiken har laddats ner",
"advanced_ssh_key_required_warning": "En offentlig nyckel krävs för SSH-åtkomst. Utan en kommer du inte att kunna ansluta.",
"advanced_success_loopback_disabled": "Endast loopback-läge inaktiverat. Starta om enheten för att tillämpa det.",
"advanced_success_loopback_enabled": "Endast loopback-läge aktiverat. Starta om enheten för att tillämpa.",
"advanced_success_reset_config": "Konfigurationen återställdes till standardinställningarna",
@@ -826,6 +826,28 @@
"something_went_wrong": "Något gick fel. Försök igen senare eller kontakta support.",
"step_counter_step": "Steg {step}",
"subnet_mask": "Subnätmask",
"tailscale_auth_description": "Tailscale kräver autentisering. Öppna länken nedan för att logga in.",
"tailscale_connected": "Ansluten",
"tailscale_control_server_custom": "Anpassad",
"tailscale_control_server_custom_url_label": "Anpassad kontrollserver-URL",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "Standard",
"tailscale_control_server_description": "Konfigurera Tailscale-kontrollplanets slutpunkt",
"tailscale_control_server_title": "Kontrollserver",
"tailscale_control_server_update_failed": "Misslyckades med att uppdatera Tailscale-kontrollservern: {error}",
"tailscale_control_server_update_success": "Tailscale-kontrollserver uppdaterad",
"tailscale_dns_name": "DNS-namn",
"tailscale_hostname": "Värdnamn",
"tailscale_installed_not_running": "Tailscale är installerat men körs inte.",
"tailscale_installed_not_running_state": " Status: {state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "Inloggning krävs",
"tailscale_refresh": "Uppdatera",
"tailscale_save": "Spara",
"tailscale_saving": "Sparar…",
"tailscale_stopped": "Stoppad",
"tailscale_title": "Tailscale",
"time_division_days": "dagar",
"time_division_hours": "timmar",
"time_division_minutes": "minuter",
+23 -1
View File
@@ -94,9 +94,9 @@
"advanced_ssh_access_description": "新增您的 SSH 公鑰以啟用對裝置的安全遠端存取",
"advanced_ssh_access_title": "SSH 存取",
"advanced_ssh_default_user": "預設 SSH 使用者為",
"advanced_ssh_key_required_warning": "SSH 存取需要公鑰。沒有公鑰將無法連線。",
"advanced_ssh_public_key_label": "SSH 公鑰",
"advanced_ssh_public_key_placeholder": "輸入您的 SSH 公鑰",
"advanced_ssh_key_required_warning": "SSH 存取需要公鑰。沒有公鑰將無法連線。",
"advanced_success_download_diagnostics": "診斷資料下載成功",
"advanced_success_loopback_disabled": "單機回送模式已停用。請重新啟動您的裝置以套用。",
"advanced_success_loopback_enabled": "單機回送模式已啟用。請重新啟動您的裝置以套用。",
@@ -826,6 +826,28 @@
"something_went_wrong": "發生錯誤。請稍後再試或聯絡支援",
"step_counter_step": "步驟 {step}",
"subnet_mask": "子網路遮罩",
"tailscale_auth_description": "Tailscale 需要進行身分驗證。請開啟下方連結登入。",
"tailscale_connected": "已連線",
"tailscale_control_server_custom": "自訂",
"tailscale_control_server_custom_url_label": "自訂控制伺服器網址",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "預設",
"tailscale_control_server_description": "設定 Tailscale 控制平面端點",
"tailscale_control_server_title": "控制伺服器",
"tailscale_control_server_update_failed": "更新 Tailscale 控制伺服器失敗:{error}",
"tailscale_control_server_update_success": "Tailscale 控制伺服器已更新",
"tailscale_dns_name": "DNS 名稱",
"tailscale_hostname": "主機名稱",
"tailscale_installed_not_running": "Tailscale 已安裝但未執行。",
"tailscale_installed_not_running_state": " 狀態:{state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "需要登入",
"tailscale_refresh": "重新整理",
"tailscale_save": "儲存",
"tailscale_saving": "儲存中…",
"tailscale_stopped": "已停止",
"tailscale_title": "Tailscale",
"time_division_days": "天",
"time_division_hours": "小時",
"time_division_minutes": "分鐘",
+23 -1
View File
@@ -94,10 +94,10 @@
"advanced_ssh_access_description": "添加您的 SSH 公钥以启用对设备的安全远程访问。",
"advanced_ssh_access_title": "SSH 访问",
"advanced_ssh_default_user": "默认 SSH 用户为",
"advanced_ssh_key_required_warning": "SSH 访问需要公钥。没有公钥将无法连接。",
"advanced_ssh_public_key_label": "SSH 公钥",
"advanced_ssh_public_key_placeholder": "请输入您的 SSH 公钥",
"advanced_success_download_diagnostics": "诊断文件已成功下载",
"advanced_ssh_key_required_warning": "SSH 访问需要公钥。没有公钥将无法连接。",
"advanced_success_loopback_disabled": "环回模式已禁用。请重启设备以应用更改。",
"advanced_success_loopback_enabled": "环回模式已启用。请重启设备以应用更改。",
"advanced_success_reset_config": "配置已成功恢复为默认设置。",
@@ -826,6 +826,28 @@
"something_went_wrong": "出错了。请稍后重试或联系技术支持。",
"step_counter_step": "第 {step} 步",
"subnet_mask": "子网掩码",
"tailscale_auth_description": "Tailscale 需要进行身份验证。请打开下方链接登录。",
"tailscale_connected": "已连接",
"tailscale_control_server_custom": "自定义",
"tailscale_control_server_custom_url_label": "自定义控制服务器 URL",
"tailscale_control_server_custom_url_placeholder": "https://headscale.example.com",
"tailscale_control_server_default": "默认",
"tailscale_control_server_description": "配置 Tailscale 控制平面端点",
"tailscale_control_server_title": "控制服务器",
"tailscale_control_server_update_failed": "更新 Tailscale 控制服务器失败:{error}",
"tailscale_control_server_update_success": "Tailscale 控制服务器已更新",
"tailscale_dns_name": "DNS 名称",
"tailscale_hostname": "主机名",
"tailscale_installed_not_running": "Tailscale 已安装但未运行。",
"tailscale_installed_not_running_state": " 状态:{state}",
"tailscale_ipv4": "IPv4",
"tailscale_ipv6": "IPv6",
"tailscale_needs_login": "需要登录",
"tailscale_refresh": "刷新",
"tailscale_save": "保存",
"tailscale_saving": "保存中...",
"tailscale_stopped": "已停止",
"tailscale_title": "Tailscale",
"time_division_days": "天",
"time_division_hours": "小时",
"time_division_minutes": "分钟",
+15 -2
View File
@@ -1,6 +1,8 @@
import { cx } from "@/cva.config";
import LoadingSpinner from "@components/LoadingSpinner";
type SettingsItemSize = "SM" | "MD";
interface SettingsItemProps {
readonly title: string;
readonly description: string | React.ReactNode;
@@ -9,6 +11,7 @@ interface SettingsItemProps {
readonly className?: string;
readonly loading?: boolean;
readonly children?: React.ReactNode;
readonly size?: SettingsItemSize;
}
const badgeTheme = {
@@ -27,16 +30,24 @@ export function SettingsItem(props: SettingsItemProps) {
children,
className,
loading,
size = "MD",
} = props;
const badgeThemeClass = badgeTheme[badgeThemeProp];
const isSM = size === "SM";
return (
<label
className={cx("flex items-center justify-between gap-x-8 rounded select-none", className)}
>
<div className="space-y-0.5">
<div className="flex items-center gap-x-2">
<div className="flex items-center text-base font-semibold text-black dark:text-white">
<div
className={cx(
"flex items-center font-semibold text-black dark:text-white",
isSM ? "text-sm" : "text-base",
)}
>
{title}
{badge && (
<span
@@ -51,7 +62,9 @@ export function SettingsItem(props: SettingsItemProps) {
</div>
{loading && <LoadingSpinner className="h-4 w-4 text-blue-500" />}
</div>
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
<div className={cx("text-slate-700 dark:text-slate-300", isSM ? "text-xs" : "text-sm")}>
{description}
</div>
</div>
{children ? <div>{children}</div> : null}
</label>
+110 -13
View File
@@ -3,13 +3,28 @@ import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import { InputFieldWithLabel } from "@components/InputField";
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { TailscaleStatus } from "@hooks/stores";
import { useJsonRpc } from "@hooks/useJsonRpc";
import { m } from "@localizations/messages.js";
import notifications from "@/notifications";
const defaultControlURL = "https://controlplane.tailscale.com";
const controlServerModeDefault = "default";
const controlServerModeCustom = "custom";
type ControlServerMode = typeof controlServerModeDefault | typeof controlServerModeCustom;
export default function TailscaleCard() {
const { send } = useJsonRpc();
const [status, setStatus] = useState<TailscaleStatus | null>(null);
const [controlURLInput, setControlURLInput] = useState("");
const [controlServerMode, setControlServerMode] =
useState<ControlServerMode>(controlServerModeDefault);
const [isSavingControlURL, setIsSavingControlURL] = useState(false);
const refreshStatus = useCallback(() => {
send("getTailscaleStatus", {}, resp => {
@@ -17,10 +32,38 @@ export default function TailscaleCard() {
setStatus(null);
return;
}
setStatus(resp.result as TailscaleStatus);
const nextStatus = resp.result as TailscaleStatus;
setStatus(nextStatus);
const activeControlURL = nextStatus.controlURL ?? defaultControlURL;
if (activeControlURL === defaultControlURL) {
setControlServerMode(controlServerModeDefault);
setControlURLInput("");
} else {
setControlServerMode(controlServerModeCustom);
setControlURLInput(activeControlURL);
}
});
}, [send]);
const saveControlURL = useCallback(() => {
setIsSavingControlURL(true);
const nextControlURL =
controlServerMode === controlServerModeDefault ? "" : controlURLInput.trim();
send("setTailscaleControlURL", { controlURL: nextControlURL }, resp => {
setIsSavingControlURL(false);
if ("error" in resp) {
const errorMessage =
typeof resp.error.data === "string" ? resp.error.data : resp.error.message;
notifications.error(m.tailscale_control_server_update_failed({ error: errorMessage }));
return;
}
notifications.success(m.tailscale_control_server_update_success());
refreshStatus();
});
}, [controlServerMode, controlURLInput, refreshStatus, send]);
useEffect(() => {
refreshStatus();
}, [refreshStatus]);
@@ -39,7 +82,9 @@ export default function TailscaleCard() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">Tailscale</h3>
<h3 className="text-base font-bold text-slate-900 dark:text-white">
{m.tailscale_title()}
</h3>
<StatusBadge status={status} />
</div>
@@ -48,39 +93,90 @@ export default function TailscaleCard() {
size="XS"
theme="light"
type="button"
text="Refresh"
text={m.tailscale_refresh()}
LeadingIcon={LuRefreshCcw}
onClick={refreshStatus}
/>
</div>
</div>
<div className="space-y-4 border-t border-slate-800/10 pt-3 dark:border-slate-300/20">
<SettingsItem
size="SM"
title={m.tailscale_control_server_title()}
description={m.tailscale_control_server_description()}
>
<SelectMenuBasic
size="XS"
label=""
value={controlServerMode}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setControlServerMode(e.target.value as ControlServerMode)
}
options={[
{ value: controlServerModeDefault, label: m.tailscale_control_server_default() },
{ value: controlServerModeCustom, label: m.tailscale_control_server_custom() },
]}
/>
</SettingsItem>
{controlServerMode === controlServerModeCustom && (
<NestedSettingsGroup>
<InputFieldWithLabel
size="SM"
label={m.tailscale_control_server_custom_url_label()}
placeholder={m.tailscale_control_server_custom_url_placeholder()}
value={controlURLInput}
onChange={e => setControlURLInput(e.target.value)}
/>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
type="button"
text={isSavingControlURL ? m.tailscale_saving() : m.tailscale_save()}
disabled={isSavingControlURL}
onClick={saveControlURL}
/>
</div>
</NestedSettingsGroup>
)}
</div>
{status.running && status.self && (
<div className="flex-1 space-y-2">
{status.self.hostName && (
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">Hostname</span>
<span className="text-sm text-slate-600 dark:text-slate-400">
{m.tailscale_hostname()}
</span>
<span className="text-sm font-medium">{status.self.hostName}</span>
</div>
)}
{ipv4 && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">IPv4</span>
<span className="text-sm text-slate-600 dark:text-slate-400">
{m.tailscale_ipv4()}
</span>
<span className="font-mono text-[13px] font-medium">{ipv4}</span>
</div>
)}
{ipv6 && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">IPv6</span>
<span className="text-sm text-slate-600 dark:text-slate-400">
{m.tailscale_ipv6()}
</span>
<span className="font-mono text-[13px] font-medium">{ipv6}</span>
</div>
)}
{status.self.dnsName && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">DNS Name</span>
<span className="text-sm text-slate-600 dark:text-slate-400">
{m.tailscale_dns_name()}
</span>
<span className="text-sm font-medium">
{status.self.dnsName.replace(/\.$/, "")}
</span>
@@ -92,7 +188,7 @@ export default function TailscaleCard() {
{status.backendState === "NeedsLogin" && status.authURL && (
<div className="space-y-2 pt-2">
<p className="text-sm text-slate-600 dark:text-slate-400">
Tailscale requires authentication. Open the link below to log in.
{m.tailscale_auth_description()}
</p>
<a
href={status.authURL}
@@ -107,8 +203,9 @@ export default function TailscaleCard() {
{!status.running && status.backendState !== "NeedsLogin" && (
<p className="pt-2 text-sm text-slate-600 dark:text-slate-400">
Tailscale is installed but not running.
{status.backendState && ` State: ${status.backendState}`}
{m.tailscale_installed_not_running()}
{status.backendState &&
m.tailscale_installed_not_running_state({ state: status.backendState })}
</p>
)}
</div>
@@ -121,20 +218,20 @@ function StatusBadge({ status }: { status: TailscaleStatus }) {
if (status.running) {
return (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
Connected
{m.tailscale_connected()}
</span>
);
}
if (status.backendState === "NeedsLogin") {
return (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
Needs Login
{m.tailscale_needs_login()}
</span>
);
}
return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-400">
Stopped
{m.tailscale_stopped()}
</span>
);
}
+1
View File
@@ -783,6 +783,7 @@ export interface TailscaleStatus {
running: boolean;
backendState?: string;
authURL?: string;
controlURL?: string;
self?: TailscalePeer;
health?: string[];
}