Skip to content

Commit a812840

Browse files
committed
Add DNS nameservers endpoint and frontend integration: implement GetDNSNameservers in backend, update API service and handlers, and enhance Dashboard component to display DNS information including nameservers and MagicDNS status.
1 parent 4c6b825 commit a812840

File tree

7 files changed

+206
-2
lines changed

7 files changed

+206
-2
lines changed

backend/internal/handlers/handlers.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,16 @@ func (h *Handlers) GetDeviceFlows(c *gin.Context) {
157157

158158
c.JSON(http.StatusOK, flows)
159159
}
160+
161+
func (h *Handlers) GetDNSNameservers(c *gin.Context) {
162+
nameservers, err := h.tailscaleService.GetDNSNameservers()
163+
if err != nil {
164+
c.JSON(http.StatusInternalServerError, gin.H{
165+
"error": "Failed to fetch DNS nameservers",
166+
"message": err.Error(),
167+
})
168+
return
169+
}
170+
171+
c.JSON(http.StatusOK, nameservers)
172+
}

backend/internal/services/tailscale.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,48 @@ func (ts *TailscaleService) GetDeviceFlows(deviceID string) (map[string]interfac
460460

461461
return flows, nil
462462
}
463+
464+
// GetDNSNameservers retrieves DNS config for the tailnet
465+
func (ts *TailscaleService) GetDNSNameservers() (map[string]interface{}, error) {
466+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
467+
defer cancel()
468+
469+
// Get nameservers
470+
nameserversBody, err := ts.makeRequest(ctx, fmt.Sprintf("/tailnet/%s/dns/nameservers", ts.tailnet))
471+
if err != nil {
472+
return nil, err
473+
}
474+
475+
var result map[string]interface{}
476+
if err := json.Unmarshal(nameserversBody, &result); err != nil {
477+
return nil, err
478+
}
479+
480+
// Get preferences
481+
prefsBody, err := ts.makeRequest(ctx, fmt.Sprintf("/tailnet/%s/dns/preferences", ts.tailnet))
482+
if err == nil {
483+
var prefs map[string]interface{}
484+
if json.Unmarshal(prefsBody, &prefs) == nil {
485+
result["magicDNS"] = prefs["magicDNS"]
486+
if domains, ok := prefs["searchDomains"]; ok {
487+
result["domains"] = domains
488+
}
489+
}
490+
}
491+
492+
// Default values
493+
if result["magicDNS"] == nil {
494+
result["magicDNS"] = false
495+
}
496+
if result["domains"] == nil {
497+
result["domains"] = []string{}
498+
}
499+
500+
// Show MagicDNS resolver when enabled
501+
dns, _ := result["dns"].([]interface{})
502+
if len(dns) == 0 && result["magicDNS"] == true {
503+
result["dns"] = []string{"100.100.100.100"}
504+
}
505+
506+
return result, nil
507+
}

backend/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ func main() {
5454
api.GET("/network-logs", handlerService.GetNetworkLogs)
5555
api.GET("/network-map", handlerService.GetNetworkMap)
5656
api.GET("/devices/:deviceId/flows", handlerService.GetDeviceFlows)
57+
api.GET("/dns/nameservers", handlerService.GetDNSNameservers)
5758
}
5859

5960
var distPath string

frontend/src/components/Layout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Link, useLocation } from 'react-router-dom'
22
import { Network, Home, Menu, X, ZoomOut, FileText } from 'lucide-react'
33
import { useAppStore } from '@/lib/store'
44
import { clsx } from 'clsx'
5+
import ThemeToggle from '@/components/ThemeToggle'
56

67
interface LayoutProps {
78
children: React.ReactNode
@@ -177,7 +178,8 @@ export default function Layout({ children, networkStats, onResetZoom, onClearSel
177178
</>
178179
)}
179180

180-
{/* Connection status indicator */}
181+
<ThemeToggle />
182+
181183
<div className="flex items-center space-x-2" role="status" aria-live="polite">
182184
<div className="h-2 w-2 bg-green-400 rounded-full animate-pulse" aria-hidden="true"></div>
183185
<span className="text-sm text-gray-600 dark:text-gray-400">Connected</span>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useState, useRef, useEffect } from 'react'
2+
import { Sun, Moon, Monitor, Check } from 'lucide-react'
3+
import { useTheme } from '@/contexts/ThemeContext'
4+
import { clsx } from 'clsx'
5+
6+
export default function ThemeToggle() {
7+
const { theme, setTheme, effectiveTheme } = useTheme()
8+
const [isOpen, setIsOpen] = useState(false)
9+
const dropdownRef = useRef<HTMLDivElement>(null)
10+
11+
const options = [
12+
{ value: 'light' as const, label: 'Light', icon: Sun },
13+
{ value: 'dark' as const, label: 'Dark', icon: Moon },
14+
{ value: 'auto' as const, label: 'System', icon: Monitor },
15+
]
16+
17+
const current = options.find(opt => opt.value === theme) || options[2]
18+
const Icon = current.icon
19+
20+
useEffect(() => {
21+
if (!isOpen) return
22+
23+
const handleClick = (e: MouseEvent) => {
24+
if (!dropdownRef.current?.contains(e.target as Node)) {
25+
setIsOpen(false)
26+
}
27+
}
28+
29+
const handleEscape = (e: KeyboardEvent) => {
30+
if (e.key === 'Escape') setIsOpen(false)
31+
}
32+
33+
document.addEventListener('mousedown', handleClick)
34+
document.addEventListener('keydown', handleEscape)
35+
36+
return () => {
37+
document.removeEventListener('mousedown', handleClick)
38+
document.removeEventListener('keydown', handleEscape)
39+
}
40+
}, [isOpen])
41+
42+
return (
43+
<div className="relative" ref={dropdownRef}>
44+
<button
45+
onClick={() => setIsOpen(!isOpen)}
46+
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
47+
aria-label={`Current theme: ${current.label}`}
48+
>
49+
<Icon className="w-4 h-4" />
50+
</button>
51+
52+
{isOpen && (
53+
<div className="absolute right-0 mt-2 w-40 rounded-lg shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 py-1 z-50">
54+
{options.map((option) => {
55+
const OptionIcon = option.icon
56+
const selected = theme === option.value
57+
58+
return (
59+
<button
60+
key={option.value}
61+
onClick={() => {
62+
setTheme(option.value)
63+
setIsOpen(false)
64+
}}
65+
className={clsx(
66+
'w-full px-3 py-2 text-sm text-left flex items-center justify-between',
67+
'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
68+
)}
69+
>
70+
<div className="flex items-center">
71+
<OptionIcon className="w-4 h-4 mr-2" />
72+
<span>{option.label}</span>
73+
{option.value === 'auto' && theme === 'auto' && (
74+
<span className="ml-1 text-xs text-gray-500 dark:text-gray-400">
75+
({effectiveTheme})
76+
</span>
77+
)}
78+
</div>
79+
{selected && <Check className="w-4 h-4 text-blue-600 dark:text-blue-400" />}
80+
</button>
81+
)
82+
})}
83+
</div>
84+
)}
85+
</div>
86+
)
87+
}

frontend/src/lib/api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ class TailscaleAPI {
8686
return await this.request(`/api/devices/${deviceId}/flows`)
8787
}
8888

89+
// Get DNS nameservers
90+
async getDNSNameservers(): Promise<{ dns: string[], magicDNS: boolean, domains: string[] }> {
91+
return await this.request('/api/dns/nameservers')
92+
}
93+
8994
// Health check
9095
async healthCheck(): Promise<{ status: string, version?: string }> {
9196
return await this.request('/health')
@@ -134,6 +139,10 @@ export const networkLogsFetcher = async (url: string): Promise<NetworkFlowLog[]>
134139
return tailscaleAPI.getNetworkLogs(queryParams)
135140
}
136141

142+
export const dnsNameserversFetcher = async (): Promise<{ dns: string[], magicDNS: boolean, domains: string[] }> => {
143+
return tailscaleAPI.getDNSNameservers()
144+
}
145+
137146
// SWR fetcher function with better error handling
138147
export const fetcher = async (url: string) => {
139148
try {

frontend/src/pages/Dashboard.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react'
22
import { Activity, Users, Globe, TrendingUp, AlertCircle, Wifi } from 'lucide-react'
33
import useSWR from 'swr'
44
import Layout from '@/components/Layout'
5-
import { devicesFetcher } from '@/lib/api'
5+
import { devicesFetcher, dnsNameserversFetcher } from '@/lib/api'
66
import type { TailscaleDevice } from '@/types/tailscale'
77
import { CardSkeleton, DeviceListSkeleton } from '@/components/LoadingSkeleton'
88
import EmptyState from '@/components/EmptyState'
@@ -14,6 +14,16 @@ export default function Dashboard() {
1414
revalidateOnFocus: false
1515
})
1616

17+
// Fetch DNS nameservers
18+
const { data: dnsData } = useSWR<{ dns: string[], magicDNS: boolean, domains: string[] }>(
19+
'/dns/nameservers',
20+
dnsNameserversFetcher,
21+
{
22+
errorRetryCount: 2,
23+
revalidateOnFocus: false
24+
}
25+
)
26+
1727
// Calculate metrics from the real data
1828
const metrics = React.useMemo(() => {
1929
if (!devices) {
@@ -149,6 +159,43 @@ export default function Dashboard() {
149159
</div>
150160
)}
151161

162+
{/* DNS Info */}
163+
{dnsData && (
164+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm p-6">
165+
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">DNS</h3>
166+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
167+
<div>
168+
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Nameservers</p>
169+
<div className="mt-2 space-y-1">
170+
{dnsData.dns?.length > 0 ? (
171+
dnsData.dns.map((ns: string) => (
172+
<p key={ns} className="text-sm font-mono text-gray-900 dark:text-gray-100">{ns}</p>
173+
))
174+
) : (
175+
<p className="text-sm text-gray-600 dark:text-gray-300">Default</p>
176+
)}
177+
</div>
178+
</div>
179+
<div>
180+
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">MagicDNS</p>
181+
<p className="mt-2 text-sm text-gray-900 dark:text-gray-100">
182+
{dnsData.magicDNS ? 'Enabled' : 'Disabled'}
183+
</p>
184+
{dnsData.domains?.length > 0 && (
185+
<>
186+
<p className="mt-3 text-sm font-medium text-gray-500 dark:text-gray-400">Search Domains</p>
187+
<div className="mt-1">
188+
{dnsData.domains.map((d: string) => (
189+
<span key={d} className="text-sm text-gray-600 dark:text-gray-300">{d} </span>
190+
))}
191+
</div>
192+
</>
193+
)}
194+
</div>
195+
</div>
196+
</div>
197+
)}
198+
152199
{/* Error display */}
153200
{error && (
154201
<div

0 commit comments

Comments
 (0)