mirror of https://github.com/glanceapp/glance.git
wstępna wersja widżetu tailscale
- cała funkcjonalność wersji custom-api - możliwość kopiowania ipv4 danego urządzenia planowane: - możliwość włączenia ssh tailscale - zmiana exit-node - zmiana advertise-routespull/878/head
parent
18f8b1a1e0
commit
08156e7b52
@ -0,0 +1,112 @@
|
||||
# Natywny Widget Tailscale
|
||||
|
||||
## Opis
|
||||
Natywny widget Tailscale dla Glance oferujący większe możliwości i łatwiejszą konfigurację niż wersja custom-api.
|
||||
|
||||
## Funkcje
|
||||
|
||||
### Co oferuje natywny widget:
|
||||
- ✅ Łatwiejsza konfiguracja (bez potrzeby pisania szablonów HTML)
|
||||
- ✅ Automatyczne parsowanie danych z API Tailscale
|
||||
- ✅ Zachowana kolorystyka z wersji custom-api
|
||||
- ✅ Wskaźniki:
|
||||
- Aktualizacji dostępnych (niebieski punkt)
|
||||
- Status online/offline (zielony/czerwony punkt)
|
||||
- Informacje o ostatniej aktywności
|
||||
- ✅ Efekty hover pokazujące adres IP urządzenia
|
||||
- ✅ **Łatwe kopiowanie IP jednym kliknięciem** (kliknij bezpośrednio na IP)
|
||||
- ✅ Wizualny feedback przy kopiowaniu (tło zmienia się na zielone z ✓)
|
||||
- ✅ Działa w HTTP i HTTPS (fallback dla starszych przeglądarek)
|
||||
- ✅ Możliwość kontrolowania liczby widocznych urządzeń
|
||||
- ✅ Opcjonalne pokazywanie wskaźnika "online"
|
||||
|
||||
### Możliwości rozszerzenia w przyszłości:
|
||||
- Zarządzanie urządzeniami
|
||||
- Włączanie/wyłączanie tras
|
||||
- Zarządzanie kluczami API
|
||||
- Statystyki ruchu
|
||||
- Powiadomienia o zmianach
|
||||
|
||||
## Konfiguracja
|
||||
|
||||
### Minimalna konfiguracja:
|
||||
```yaml
|
||||
- type: tailscale
|
||||
token: your-tailscale-api-token
|
||||
```
|
||||
|
||||
### Pełna konfiguracja:
|
||||
```yaml
|
||||
- type: tailscale
|
||||
title: Tailscale # Opcjonalny, domyślnie "Tailscale"
|
||||
title-url: https://login.tailscale.com/admin/machines # Opcjonalny
|
||||
token: your-tailscale-api-token # Wymagany
|
||||
tailnet: "-" # Opcjonalny, domyślnie "-" (current tailnet)
|
||||
url: https://api.tailscale.com/api/v2/tailnet/-/devices # Opcjonalny, można nadpisać URL API
|
||||
cache: 10m # Opcjonalny, domyślnie 10m
|
||||
collapse-after: 4 # Opcjonalny, domyślnie 4
|
||||
show-online-indicator: false # Opcjonalny, domyślnie false
|
||||
```
|
||||
|
||||
## Parametry
|
||||
|
||||
### `token` (wymagany)
|
||||
Token API Tailscale. Możesz wygenerować go w panelu administracyjnym Tailscale:
|
||||
- Przejdź do https://login.tailscale.com/admin/settings/keys
|
||||
- Kliknij "Generate API access token"
|
||||
- Skopiuj token i użyj go w konfiguracji
|
||||
|
||||
### `tailnet` (opcjonalny)
|
||||
Nazwa tailnet. Domyślnie "-" oznacza bieżący tailnet. Możesz podać konkretną nazwę, jeśli masz dostęp do wielu tailnetów.
|
||||
|
||||
### `url` (opcjonalny)
|
||||
Niestandardowy URL API. Domyślnie widget używa oficjalnego API Tailscale.
|
||||
|
||||
### `cache` (opcjonalny)
|
||||
Czas cache'owania danych. Domyślnie 10 minut. Przykłady: `5m`, `1h`, `30s`.
|
||||
|
||||
### `collapse-after` (opcjonalny)
|
||||
Liczba urządzeń widocznych przed przyciskiem "SHOW MORE". Domyślnie 4. Ustaw na `-1`, aby nigdy nie zwijać listy.
|
||||
|
||||
### `show-online-indicator` (opcjonalny)
|
||||
Czy pokazywać zielony wskaźnik dla urządzeń online. Domyślnie `false` (pokazywany jest tylko czerwony wskaźnik dla urządzeń offline).
|
||||
|
||||
## Wizualne elementy
|
||||
|
||||
Widget zachowuje całą kolorystykę z wersji custom-api:
|
||||
- **Kolor podstawowy** (`--color-primary`) - nazwa urządzenia i tło IP po hover
|
||||
- **Kolor pozytywny** (`--color-positive`) - wskaźnik online (jeśli włączony) i tło IP po skopiowaniu
|
||||
- **Kolor negatywny** (`--color-negative`) - wskaźnik offline
|
||||
- **Kolor podstawowy** (`--color-primary`) - wskaźnik dostępnej aktualizacji
|
||||
|
||||
### Kopiowanie adresu IP
|
||||
Po najechaniu na wiersz urządzenia:
|
||||
1. Zamiast informacji o systemie i użytkowniku pojawia się adres IP
|
||||
2. Adres IP jest klikalny (hover zmienia tło na niebieski)
|
||||
3. Kliknięcie w IP kopiuje je do schowka
|
||||
4. Po skopiowaniu tło zmienia się na zielone i pojawia się ✓ na 2 sekundy
|
||||
5. Działa w każdej przeglądarce dzięki mechanizmowi fallback
|
||||
|
||||
## Przykładowe zastosowania
|
||||
|
||||
### Podstawowy monitoring:
|
||||
```yaml
|
||||
- type: tailscale
|
||||
token: ${TAILSCALE_TOKEN}
|
||||
```
|
||||
|
||||
### Monitoring ze wskaźnikami online:
|
||||
```yaml
|
||||
- type: tailscale
|
||||
token: ${TAILSCALE_TOKEN}
|
||||
show-online-indicator: true
|
||||
collapse-after: 10
|
||||
```
|
||||
|
||||
### Monitoring z niestandardowym cache:
|
||||
```yaml
|
||||
- type: tailscale
|
||||
token: ${TAILSCALE_TOKEN}
|
||||
cache: 5m
|
||||
title: Moja Sieć Tailscale
|
||||
```
|
||||
@ -0,0 +1,144 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<style>
|
||||
.tailscale-device-info-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
.tailscale-device-info {
|
||||
display: flex;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tailscale-device-ip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tailscale-device-info-container:hover .tailscale-device-info {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tailscale-device-info-container:hover .tailscale-device-ip {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tailscale-update-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-primary);
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tailscale-offline-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-negative);
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tailscale-online-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-positive);
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tailscale-device-name-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tailscale-indicators-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tailscale-ip-clickable {
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tailscale-ip-clickable:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-background);
|
||||
}
|
||||
|
||||
.tailscale-ip-clickable.copied {
|
||||
background-color: var(--color-positive);
|
||||
color: var(--color-background);
|
||||
}
|
||||
|
||||
.tailscale-ip-clickable.copied::after {
|
||||
content: ' ✓';
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{ if .Devices }}
|
||||
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range .Devices }}
|
||||
<li>
|
||||
<div class="flex items-center gap-10">
|
||||
<div class="tailscale-device-name-container grow">
|
||||
<span class="size-h4 block text-truncate color-primary">
|
||||
{{ .ShortName }}
|
||||
</span>
|
||||
<div class="tailscale-indicators-container">
|
||||
{{ if .UpdateAvailable }}
|
||||
<span class="tailscale-update-indicator" data-popover-type="text" data-popover-text="Aktualizacja dostępna"></span>
|
||||
{{ end }}
|
||||
|
||||
{{ if not .IsOnline }}
|
||||
<span class="tailscale-offline-indicator" data-popover-type="text"
|
||||
data-popover-text="Offline - Ostatnio aktywny {{ .LastSeenStr }}"></span>
|
||||
{{ else if $.ShowOnlineIndicator }}
|
||||
<span class="tailscale-online-indicator" data-popover-type="text" data-popover-text="Online"></span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tailscale-device-info-container">
|
||||
<ul class="list-horizontal-text tailscale-device-info">
|
||||
<li>{{ .OS }}</li>
|
||||
<li>{{ .User }}</li>
|
||||
</ul>
|
||||
<div class="tailscale-device-ip">
|
||||
<span class="tailscale-ip-clickable"
|
||||
data-ip="{{ .PrimaryAddress }}"
|
||||
title="Kliknij aby skopiować IP"
|
||||
onclick="event.stopPropagation(); event.preventDefault(); var ip = this.getAttribute('data-ip'); var self = this; var textarea = document.createElement('textarea'); textarea.value = ip; textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); try { document.execCommand('copy'); self.classList.add('copied'); setTimeout(function() { self.classList.remove('copied'); }, 2000); } catch(err) { console.error('Kopiowanie nie powiodło się:', err); } document.body.removeChild(textarea);">
|
||||
{{ .PrimaryAddress }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ else }}
|
||||
<p class="text-center color-paragraph">Brak urządzeń</p>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@ -0,0 +1,145 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var tailscaleWidgetTemplate = mustParseTemplate("tailscale.html", "widget-base.html")
|
||||
|
||||
type tailscaleWidget struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token"`
|
||||
Tailnet string `yaml:"tailnet"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
ShowOnlineIndicator bool `yaml:"show-online-indicator"`
|
||||
Devices []tailscaleDevice
|
||||
}
|
||||
|
||||
type tailscaleDevice struct {
|
||||
ID string
|
||||
Name string
|
||||
ShortName string
|
||||
OS string
|
||||
User string
|
||||
Addresses []string
|
||||
PrimaryAddress string
|
||||
LastSeen time.Time
|
||||
LastSeenStr string
|
||||
UpdateAvailable bool
|
||||
IsOnline bool
|
||||
}
|
||||
|
||||
type tailscaleAPIResponse struct {
|
||||
Devices []tailscaleAPIDevice `json:"devices"`
|
||||
}
|
||||
|
||||
type tailscaleAPIDevice struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
User string `json:"user"`
|
||||
Addresses []string `json:"addresses"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
}
|
||||
|
||||
func (widget *tailscaleWidget) initialize() error {
|
||||
widget.withTitle("Tailscale").withCacheDuration(10 * time.Minute)
|
||||
|
||||
if widget.Token == "" {
|
||||
return fmt.Errorf("token is required")
|
||||
}
|
||||
|
||||
if widget.Tailnet == "" {
|
||||
widget.Tailnet = "-" // Default to current tailnet
|
||||
}
|
||||
|
||||
if widget.URL == "" {
|
||||
widget.URL = fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/devices", widget.Tailnet)
|
||||
}
|
||||
|
||||
if widget.CollapseAfter <= 0 {
|
||||
widget.CollapseAfter = 4
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *tailscaleWidget) update(ctx context.Context) {
|
||||
devices, err := widget.fetchDevices()
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
}
|
||||
|
||||
widget.Devices = devices
|
||||
}
|
||||
|
||||
func (widget *tailscaleWidget) fetchDevices() ([]tailscaleDevice, error) {
|
||||
request, err := http.NewRequest("GET", widget.URL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set("Authorization", "Bearer "+widget.Token)
|
||||
|
||||
apiResponse, err := decodeJsonFromRequest[tailscaleAPIResponse](defaultHTTPClient, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices := make([]tailscaleDevice, 0)
|
||||
now := time.Now()
|
||||
|
||||
for _, apiDevice := range apiResponse.Devices {
|
||||
device := tailscaleDevice{
|
||||
ID: apiDevice.ID,
|
||||
Name: apiDevice.Name,
|
||||
ShortName: extractShortName(apiDevice.Name),
|
||||
OS: apiDevice.OS,
|
||||
User: apiDevice.User,
|
||||
Addresses: apiDevice.Addresses,
|
||||
UpdateAvailable: apiDevice.UpdateAvailable,
|
||||
}
|
||||
|
||||
// Get primary address
|
||||
if len(apiDevice.Addresses) > 0 {
|
||||
device.PrimaryAddress = apiDevice.Addresses[0]
|
||||
}
|
||||
|
||||
// Parse last seen time
|
||||
if apiDevice.LastSeen != "" {
|
||||
lastSeen, err := time.Parse(time.RFC3339, apiDevice.LastSeen)
|
||||
if err == nil {
|
||||
device.LastSeen = lastSeen
|
||||
device.LastSeenStr = lastSeen.Format("Jan 2 3:04pm")
|
||||
|
||||
// Device is considered online if last seen within 10 seconds
|
||||
device.IsOnline = lastSeen.After(now.Add(-10 * time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
devices = append(devices, device)
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// extractShortName extracts the hostname before the first dot
|
||||
func extractShortName(fullName string) string {
|
||||
if idx := strings.Index(fullName, "."); idx > 0 {
|
||||
return fullName[:idx]
|
||||
}
|
||||
return fullName
|
||||
}
|
||||
|
||||
func (widget *tailscaleWidget) Render() template.HTML {
|
||||
return widget.renderTemplate(widget, tailscaleWidgetTemplate)
|
||||
}
|
||||
Loading…
Reference in New Issue