UX improvements.
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -329,24 +330,87 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDaemonStatus checks whether each managed daemon is active.
|
// getDaemonStatus checks whether each managed daemon is active and gets its version.
|
||||||
func getDaemonStatus() map[string]map[string]bool {
|
func getDaemonStatus() map[string]map[string]any {
|
||||||
daemons := map[string]string{
|
type daemonInfo struct {
|
||||||
"dnsmasq": "dnsmasq.service",
|
unit string
|
||||||
"haproxy": "haproxy.service",
|
verCmd []string // command to get version
|
||||||
"wireguard": "wg-quick@wg0.service",
|
verParse func(string) string // extract version from output
|
||||||
"crowdsec": "crowdsec.service",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make(map[string]map[string]bool, len(daemons)+1)
|
firstWord := func(s string) string {
|
||||||
for name, unit := range daemons {
|
if i := strings.IndexByte(s, ' '); i > 0 {
|
||||||
err := exec.Command("systemctl", "is-active", "--quiet", unit).Run()
|
return s[:i]
|
||||||
result[name] = map[string]bool{"active": err == nil}
|
}
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
daemons := map[string]daemonInfo{
|
||||||
|
"dnsmasq": {
|
||||||
|
unit: "dnsmasq.service",
|
||||||
|
verCmd: []string{"dnsmasq", "--version"},
|
||||||
|
verParse: func(out string) string {
|
||||||
|
// "Dnsmasq version 2.90 ..."
|
||||||
|
if i := strings.Index(out, "version "); i >= 0 {
|
||||||
|
return firstWord(out[i+8:])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"haproxy": {
|
||||||
|
unit: "haproxy.service",
|
||||||
|
verCmd: []string{"haproxy", "-v"},
|
||||||
|
verParse: func(out string) string {
|
||||||
|
// "HAProxy version 2.8.5-1 ..."
|
||||||
|
if i := strings.Index(out, "version "); i >= 0 {
|
||||||
|
return firstWord(out[i+8:])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"wireguard": {
|
||||||
|
unit: "wg-quick@wg0.service",
|
||||||
|
verCmd: []string{"wg", "--version"},
|
||||||
|
verParse: func(out string) string {
|
||||||
|
// "wireguard-tools v1.0.20210914 - ..."
|
||||||
|
if i := strings.Index(out, " v"); i >= 0 {
|
||||||
|
return firstWord(out[i+1:])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"crowdsec": {
|
||||||
|
unit: "crowdsec.service",
|
||||||
|
verCmd: []string{"cscli", "version", "--raw"},
|
||||||
|
verParse: func(out string) string {
|
||||||
|
return firstWord(out)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]map[string]any, len(daemons)+1)
|
||||||
|
for name, info := range daemons {
|
||||||
|
err := exec.Command("systemctl", "is-active", "--quiet", info.unit).Run()
|
||||||
|
entry := map[string]any{"active": err == nil}
|
||||||
|
if out, verErr := exec.Command(info.verCmd[0], info.verCmd[1:]...).Output(); verErr == nil {
|
||||||
|
if v := info.verParse(string(out)); v != "" {
|
||||||
|
entry["version"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[name] = entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// nftables has no persistent service — check if the wild-cloud table exists
|
// nftables has no persistent service — check if the wild-cloud table exists
|
||||||
err := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
|
nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
|
||||||
result["nftables"] = map[string]bool{"active": err == nil}
|
nftEntry := map[string]any{"active": nftErr == nil}
|
||||||
|
if out, verErr := exec.Command("nft", "--version").Output(); verErr == nil {
|
||||||
|
// "nftables v1.0.9 (Old Doc Yak #3)"
|
||||||
|
s := string(out)
|
||||||
|
if i := strings.Index(s, " v"); i >= 0 {
|
||||||
|
nftEntry["version"] = firstWord(s[i+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result["nftables"] = nftEntry
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type cloudflareZone struct {
|
type cloudflareZone struct {
|
||||||
@@ -10,11 +12,18 @@ type cloudflareZone struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cloudflarePermissions struct {
|
||||||
|
Zone bool `json:"zone"` // can list zones
|
||||||
|
DNS bool `json:"dns"` // can read/edit DNS records
|
||||||
|
}
|
||||||
|
|
||||||
type cloudflareVerifyResponse struct {
|
type cloudflareVerifyResponse struct {
|
||||||
TokenConfigured bool `json:"tokenConfigured"`
|
TokenConfigured bool `json:"tokenConfigured"`
|
||||||
TokenValid bool `json:"tokenValid"`
|
TokenValid bool `json:"tokenValid"`
|
||||||
TokenStatus string `json:"tokenStatus"`
|
TokenStatus string `json:"tokenStatus"`
|
||||||
Zones []cloudflareZone `json:"zones"`
|
Zones []cloudflareZone `json:"zones"`
|
||||||
|
RequiredZones []string `json:"requiredZones"`
|
||||||
|
Permissions cloudflarePermissions `json:"permissions"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +56,15 @@ func (api *API) CloudflareVerify(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp.Zones = zones
|
resp.Zones = zones
|
||||||
|
resp.Permissions.Zone = true
|
||||||
|
|
||||||
|
// Test DNS access on the first available zone
|
||||||
|
if len(zones) > 0 {
|
||||||
|
resp.Permissions.DNS = testCloudflareDNSAccess(token, zones[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive required zones from registered domains
|
||||||
|
resp.RequiredZones = api.getRequiredZones()
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, resp)
|
respondJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
@@ -78,6 +96,36 @@ func (api *API) CloudflareSetZoneID(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondMessage(w, http.StatusOK, "Zone ID saved")
|
respondMessage(w, http.StatusOK, "Zone ID saved")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getRequiredZones extracts unique root zones from all registered domains.
|
||||||
|
// e.g., "app.cloud.payne.io" → "payne.io"
|
||||||
|
func (api *API) getRequiredZones() []string {
|
||||||
|
doms, err := api.domains.List()
|
||||||
|
if err != nil || len(doms) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var zones []string
|
||||||
|
for _, dom := range doms {
|
||||||
|
zone := rootZone(dom.DomainName)
|
||||||
|
if zone != "" && !seen[zone] {
|
||||||
|
seen[zone] = true
|
||||||
|
zones = append(zones, zone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zones
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootZone extracts the registrable zone from a FQDN.
|
||||||
|
// "app.cloud.payne.io" → "payne.io", "foo.example.com" → "example.com"
|
||||||
|
func rootZone(domain string) string {
|
||||||
|
parts := strings.Split(domain, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(parts[len(parts)-2:], ".")
|
||||||
|
}
|
||||||
|
|
||||||
func verifyCloudflareToken(apiToken string) (string, error) {
|
func verifyCloudflareToken(apiToken string) (string, error) {
|
||||||
req, err := http.NewRequest(http.MethodGet,
|
req, err := http.NewRequest(http.MethodGet,
|
||||||
"https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
"https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
||||||
@@ -103,6 +151,24 @@ func verifyCloudflareToken(apiToken string) (string, error) {
|
|||||||
return result.Result.Status, nil
|
return result.Result.Status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testCloudflareDNSAccess checks if the token can list DNS records for a zone.
|
||||||
|
func testCloudflareDNSAccess(apiToken, zoneID string) bool {
|
||||||
|
req, err := http.NewRequest(http.MethodGet,
|
||||||
|
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?per_page=1", zoneID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode == http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
func listCloudflareZones(apiToken string) ([]cloudflareZone, error) {
|
func listCloudflareZones(apiToken string) ([]cloudflareZone, error) {
|
||||||
req, err := http.NewRequest(http.MethodGet,
|
req, err := http.NewRequest(http.MethodGet,
|
||||||
"https://api.cloudflare.com/client/v4/zones", nil)
|
"https://api.cloudflare.com/client/v4/zones", nil)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning, Network } from 'lucide-react';
|
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning, Network, ChevronRight } from 'lucide-react';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -17,10 +19,17 @@ import {
|
|||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { useCentralStatus } from '../hooks/useCentralStatus';
|
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||||
|
|
||||||
|
const ADVANCED_OPEN_KEY = 'wild-central:advanced-open';
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
const { data: centralStatus } = useCentralStatus();
|
const { data: centralStatus } = useCentralStatus();
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(() => localStorage.getItem(ADVANCED_OPEN_KEY) === 'true');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(ADVANCED_OPEN_KEY, String(advancedOpen));
|
||||||
|
}, [advancedOpen]);
|
||||||
|
|
||||||
const cycleTheme = () => {
|
const cycleTheme = () => {
|
||||||
if (theme === 'light') {
|
if (theme === 'light') {
|
||||||
@@ -158,8 +167,15 @@ export function AppSidebar() {
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Advanced</SidebarGroupLabel>
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarGroupLabel className="cursor-pointer select-none">
|
||||||
|
Advanced
|
||||||
|
<ChevronRight className={`ml-auto h-3.5 w-3.5 transition-transform duration-200 ${advancedOpen ? 'rotate-90' : ''}`} />
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
|
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
|
||||||
@@ -182,7 +198,9 @@ export function AppSidebar() {
|
|||||||
})}
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
|
</CollapsibleContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
</Collapsible>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|||||||
@@ -82,8 +82,12 @@ function titleCaseScenario(s: string): string {
|
|||||||
).join(' ');
|
).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function friendlyReason(reason: string): string {
|
function friendlyScenario(scenario: string, origin?: string): string {
|
||||||
if (!reason) return 'Community blocklist';
|
if (!scenario) {
|
||||||
|
if (origin === 'capi') return 'Community blocklist';
|
||||||
|
if (origin === 'manual' || origin === 'cscli') return 'Manual';
|
||||||
|
return origin || 'Unknown';
|
||||||
|
}
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
'crowdsecurity/http-scan-classb': 'HTTP Scanner',
|
'crowdsecurity/http-scan-classb': 'HTTP Scanner',
|
||||||
'crowdsecurity/ssh-slow-bruteforce': 'SSH Brute Force (slow)',
|
'crowdsecurity/ssh-slow-bruteforce': 'SSH Brute Force (slow)',
|
||||||
@@ -96,8 +100,8 @@ function friendlyReason(reason: string): string {
|
|||||||
'crowdsecurity/http-bad-user-agent': 'Bad User Agent',
|
'crowdsecurity/http-bad-user-agent': 'Bad User Agent',
|
||||||
'crowdsecurity/iptables-scan-multi_ports': 'Port Scanner',
|
'crowdsecurity/iptables-scan-multi_ports': 'Port Scanner',
|
||||||
};
|
};
|
||||||
if (map[reason]) return map[reason];
|
if (map[scenario]) return map[scenario];
|
||||||
const short = reason.includes('/') ? reason.split('/').slice(1).join(' ') : reason;
|
const short = scenario.includes('/') ? scenario.split('/').slice(1).join(' ') : scenario;
|
||||||
return titleCaseScenario(short);
|
return titleCaseScenario(short);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,10 +298,10 @@ export function CrowdSecComponent() {
|
|||||||
/>
|
/>
|
||||||
{scenarioHubUrl(reason) ? (
|
{scenarioHubUrl(reason) ? (
|
||||||
<a href={scenarioHubUrl(reason)!} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground hover:underline">
|
<a href={scenarioHubUrl(reason)!} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground hover:underline">
|
||||||
{friendlyReason(reason)}
|
{friendlyScenario(reason)}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">{friendlyReason(reason)}</span>
|
<span className="text-muted-foreground">{friendlyScenario(reason)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-xs tabular-nums">{count.toLocaleString()}</span>
|
<span className="font-mono text-xs tabular-nums">{count.toLocaleString()}</span>
|
||||||
@@ -411,10 +415,10 @@ export function CrowdSecComponent() {
|
|||||||
</div>
|
</div>
|
||||||
{scenarioHubUrl(alert.scenario) ? (
|
{scenarioHubUrl(alert.scenario) ? (
|
||||||
<a href={scenarioHubUrl(alert.scenario)!} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground whitespace-nowrap hover:text-foreground hover:underline">
|
<a href={scenarioHubUrl(alert.scenario)!} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground whitespace-nowrap hover:text-foreground hover:underline">
|
||||||
{friendlyReason(alert.scenario)}
|
{friendlyScenario(alert.scenario)}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{friendlyReason(alert.scenario)}</span>
|
<span className="text-xs text-muted-foreground whitespace-nowrap">{friendlyScenario(alert.scenario)}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-mono text-muted-foreground whitespace-nowrap">{alert.machineId?.replace(/^wc-/, '') ?? '—'}</span>
|
<span className="text-xs font-mono text-muted-foreground whitespace-nowrap">{alert.machineId?.replace(/^wc-/, '') ?? '—'}</span>
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatRelativeTime(alert.createdAt)}</span>
|
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatRelativeTime(alert.createdAt)}</span>
|
||||||
@@ -528,7 +532,7 @@ export function CrowdSecComponent() {
|
|||||||
}
|
}
|
||||||
<span className="font-mono truncate">{d.value}</span>
|
<span className="font-mono truncate">{d.value}</span>
|
||||||
<Badge variant="outline" className="text-xs h-4 shrink-0">{d.type}</Badge>
|
<Badge variant="outline" className="text-xs h-4 shrink-0">{d.type}</Badge>
|
||||||
<span className="text-muted-foreground truncate">{friendlyReason(d.reason)}</span>
|
<span className="text-muted-foreground truncate">{friendlyScenario(d.scenario, d.origin)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{d.duration && (
|
{d.duration && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Card } from './ui/card';
|
import { Card } from './ui/card';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
@@ -7,7 +7,8 @@ import { Alert, AlertDescription } from './ui/alert';
|
|||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
|
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
|
||||||
BookOpen, Globe, Router, Server, RotateCw, Key,
|
BookOpen, Globe, Router, RotateCw, Key,
|
||||||
|
Shield, Eye, ArrowLeftRight, Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCloudflare } from '../hooks/useCloudflare';
|
import { useCloudflare } from '../hooks/useCloudflare';
|
||||||
import { useCentralStatus } from '../hooks/useCentralStatus';
|
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||||
@@ -15,6 +16,45 @@ import { useVpn } from '../hooks/useVpn';
|
|||||||
import { secretsApi, dnsSettingsApi, nftablesConfigApi } from '../services/api';
|
import { secretsApi, dnsSettingsApi, nftablesConfigApi } from '../services/api';
|
||||||
import { usePageHelp } from '../hooks/usePageHelp';
|
import { usePageHelp } from '../hooks/usePageHelp';
|
||||||
|
|
||||||
|
const SERVICES = [
|
||||||
|
{ key: 'dnsmasq', label: 'DNS / DHCP', icon: Globe },
|
||||||
|
{ key: 'haproxy', label: 'HAProxy', icon: ArrowLeftRight },
|
||||||
|
{ key: 'nftables', label: 'Firewall', icon: Shield },
|
||||||
|
{ key: 'wireguard', label: 'VPN', icon: Lock },
|
||||||
|
{ key: 'crowdsec', label: 'CrowdSec', icon: Eye },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatUptime(totalSeconds: number): string {
|
||||||
|
const days = Math.floor(totalSeconds / 86400);
|
||||||
|
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLiveUptime(serverSeconds: number | undefined): string {
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const baseRef = useRef(serverSeconds ?? 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverSeconds === undefined) return;
|
||||||
|
baseRef.current = serverSeconds;
|
||||||
|
setElapsed(0);
|
||||||
|
}, [serverSeconds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverSeconds === undefined) return;
|
||||||
|
const id = setInterval(() => setElapsed(e => e + 1), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [serverSeconds]);
|
||||||
|
|
||||||
|
return formatUptime(baseRef.current + elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardComponent() {
|
export function DashboardComponent() {
|
||||||
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
|
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
|
||||||
const { verification, isLoading: cfLoading } = useCloudflare();
|
const { verification, isLoading: cfLoading } = useCloudflare();
|
||||||
@@ -44,11 +84,22 @@ export function DashboardComponent() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Warning conditions
|
|
||||||
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
|
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
|
||||||
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
|
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
|
||||||
const hasWarnings = cfTokenMissing || cfTokenInvalid;
|
const hasWarnings = cfTokenMissing || cfTokenInvalid;
|
||||||
|
|
||||||
|
const daemons = (centralStatus?.daemons ?? {}) as Record<string, { active?: boolean; version?: string }>;
|
||||||
|
|
||||||
|
const uptime = useLiveUptime(centralStatus?.uptimeSeconds);
|
||||||
|
|
||||||
|
const ports = [
|
||||||
|
{ port: 443, proto: 'TCP', label: 'HTTPS' },
|
||||||
|
{ port: 80, proto: 'TCP', label: 'HTTP' },
|
||||||
|
...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []),
|
||||||
|
...((nftConfig?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[])
|
||||||
|
.map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })),
|
||||||
|
];
|
||||||
|
|
||||||
usePageHelp({
|
usePageHelp({
|
||||||
title: 'What is the Dashboard?',
|
title: 'What is the Dashboard?',
|
||||||
description: (
|
description: (
|
||||||
@@ -69,16 +120,37 @@ export function DashboardComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Page Header */}
|
{/* Page Header with system metadata */}
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<div className="p-2 bg-primary/10 rounded-lg">
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
<LayoutDashboard className="h-6 w-6 text-primary" />
|
<LayoutDashboard className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold">Dashboard</h2>
|
<h2 className="text-2xl font-semibold">Dashboard</h2>
|
||||||
<p className="text-muted-foreground">Infrastructure health and service prerequisites</p>
|
<p className="text-muted-foreground">System health overview</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!statusLoading && centralStatus ? (
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground sm:mt-1">
|
||||||
|
<span className="font-mono text-xs">{centralStatus.version}</span>
|
||||||
|
<span className="text-border">|</span>
|
||||||
|
<span>up <span className="font-mono">{uptime}</span></span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={restartDaemon}
|
||||||
|
disabled={isRestarting}
|
||||||
|
className="gap-1 h-7 text-xs"
|
||||||
|
>
|
||||||
|
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||||
|
{isRestarting ? 'Restarting...' : 'Restart'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : !statusLoading ? (
|
||||||
|
<Badge variant="destructive">Offline</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Warnings */}
|
{/* Warnings */}
|
||||||
{hasWarnings && (
|
{hasWarnings && (
|
||||||
@@ -98,12 +170,36 @@ export function DashboardComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 1. Cloudflare */}
|
{/* Services */}
|
||||||
<Card className="p-4 border-l-4 border-l-blue-500">
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Services</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
<Cloud className="h-5 w-5 text-blue-500" />
|
{SERVICES.map((svc) => (
|
||||||
<div className="font-medium">Cloudflare</div>
|
<ServiceCard
|
||||||
|
key={svc.key}
|
||||||
|
label={svc.label}
|
||||||
|
daemon={svc.key}
|
||||||
|
icon={svc.icon}
|
||||||
|
active={daemons[svc.key]?.active}
|
||||||
|
version={daemons[svc.key]?.version}
|
||||||
|
loading={statusLoading}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Configuration</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Cloudflare */}
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 bg-orange-500/10 rounded-xl">
|
||||||
|
<Cloud className="h-5 w-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold">Cloudflare</div>
|
||||||
</div>
|
</div>
|
||||||
{cfLoading ? (
|
{cfLoading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
@@ -123,29 +219,22 @@ export function DashboardComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cfLoading ? (
|
{cfLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
Checking token...
|
Verifying...
|
||||||
</div>
|
</div>
|
||||||
) : verification?.tokenValid ? (
|
) : verification?.tokenValid ? (
|
||||||
<div className="space-y-2 ml-7">
|
<div className="space-y-3">
|
||||||
{verification.zones && verification.zones.length > 0 && (
|
<div className="space-y-1">
|
||||||
<div className="flex items-start gap-2 text-sm">
|
<PermissionRow label="Zone:Read" description="List DNS zones" ok={verification.permissions?.zone} />
|
||||||
<Globe className="h-4 w-4 text-muted-foreground mt-0.5" />
|
<PermissionRow label="DNS:Edit" description="Manage DNS records" ok={verification.permissions?.dns} />
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Zones: </span>
|
|
||||||
<span className="inline-flex flex-wrap gap-1">
|
|
||||||
{verification.zones.map((zone) => (
|
|
||||||
<span key={zone.id} className="font-mono bg-muted px-2 py-0.5 rounded text-xs">
|
|
||||||
{zone.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ZoneCoverage
|
||||||
)}
|
available={verification.zones}
|
||||||
|
required={verification.requiredZones}
|
||||||
|
/>
|
||||||
{!editingToken ? (
|
{!editingToken ? (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 text-muted-foreground">
|
<Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 h-7 text-xs text-muted-foreground">
|
||||||
<Key className="h-3 w-3" />
|
<Key className="h-3 w-3" />
|
||||||
Change Token
|
Change Token
|
||||||
</Button>
|
</Button>
|
||||||
@@ -169,13 +258,13 @@ export function DashboardComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-7">
|
<div>
|
||||||
{!editingToken ? (
|
{!editingToken ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{verification?.tokenConfigured
|
{verification?.tokenConfigured
|
||||||
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
|
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
|
||||||
: 'An API token is required for DNS and TLS features'}
|
: 'Required for DNS and TLS'}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
|
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
|
||||||
<Key className="h-3 w-3" />
|
<Key className="h-3 w-3" />
|
||||||
@@ -212,116 +301,142 @@ export function DashboardComponent() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 2. Daemon Status */}
|
{/* Router */}
|
||||||
<Card className="p-4 border-l-4 border-l-purple-500">
|
<Card className="p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="p-2.5 bg-amber-500/10 rounded-xl">
|
||||||
<Server className="h-5 w-5 text-purple-500" />
|
|
||||||
<div className="font-medium">Daemon Status</div>
|
|
||||||
</div>
|
|
||||||
{statusLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
) : centralStatus ? (
|
|
||||||
<Badge variant="success" className="gap-1">
|
|
||||||
<CheckCircle className="h-3 w-3" />
|
|
||||||
Running
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="destructive">Offline</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{statusLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Loading status...
|
|
||||||
</div>
|
|
||||||
) : centralStatus ? (
|
|
||||||
<div className="space-y-3 ml-7">
|
|
||||||
{/* Service badges */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<ServiceBadge name="dnsmasq" active={centralStatus?.daemons?.dnsmasq?.active} />
|
|
||||||
<ServiceBadge name="haproxy" active={centralStatus?.daemons?.haproxy?.active} />
|
|
||||||
<ServiceBadge name="nftables" active={centralStatus?.daemons?.nftables?.active} />
|
|
||||||
<ServiceBadge name="wireguard" active={centralStatus?.daemons?.wireguard?.active} />
|
|
||||||
<ServiceBadge name="crowdsec" active={centralStatus?.daemons?.crowdsec?.active} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version and uptime */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Version</span>
|
|
||||||
<div className="font-mono font-medium mt-0.5">{centralStatus.version || 'Unknown'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Uptime</span>
|
|
||||||
<div className="font-mono mt-0.5">{centralStatus.uptime || '--'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={restartDaemon}
|
|
||||||
disabled={isRestarting}
|
|
||||||
className="gap-1"
|
|
||||||
>
|
|
||||||
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
|
||||||
{isRestarting ? 'Restarting...' : 'Restart Daemon'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground ml-7">Cannot reach the Wild Central daemon.</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 3. Gateway Router */}
|
|
||||||
<Card className="p-4 border-l-4 border-l-amber-500 bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Router className="h-5 w-5 text-amber-500" />
|
<Router className="h-5 w-5 text-amber-500" />
|
||||||
<div className="font-medium">Gateway Router</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 ml-7 text-sm text-muted-foreground">
|
<div>
|
||||||
<p>Your LAN router should be configured to work with Wild Central:</p>
|
<div className="font-semibold">Router</div>
|
||||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
<div className="text-xs text-muted-foreground">LAN setup checklist</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>Configure your LAN router so Wild Central can serve traffic:</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1.5 text-xs">
|
||||||
<li>
|
<li>
|
||||||
Set the router's DNS server to Wild Central
|
Set the router's DNS server to Wild Central
|
||||||
{dnsSettings?.ip && (
|
{dnsSettings?.ip && (
|
||||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
|
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">{dnsSettings.ip}</span>
|
||||||
{dnsSettings.ip}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Port-forward to Wild Central:{' '}
|
Forward these ports to Wild Central:
|
||||||
{[
|
<div className="flex flex-wrap gap-1 mt-1 ml-4">
|
||||||
{ port: 443, proto: 'TCP', label: 'HTTPS' },
|
{ports.map((p, i) => (
|
||||||
{ port: 80, proto: 'TCP', label: 'HTTP' },
|
<span key={i} className="font-mono bg-muted px-2 py-0.5 rounded">
|
||||||
...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []),
|
{p.port}/{p.proto}
|
||||||
...((nftConfig?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[])
|
|
||||||
.map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })),
|
|
||||||
].map((p, i) => (
|
|
||||||
<span key={i}>
|
|
||||||
{i > 0 && ', '}
|
|
||||||
<span className="font-mono">{p.port}</span>/{p.proto}
|
|
||||||
{p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
|
{p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inline badge showing a service name with a colored dot reflecting actual daemon status. */
|
function ZoneCoverage({ available, required }: {
|
||||||
function ServiceBadge({ name, active }: { name: string; active?: boolean }) {
|
available: { id: string; name: string }[] | null;
|
||||||
const dotColor = active === undefined ? 'bg-gray-400' : active ? 'bg-green-500' : 'bg-red-500';
|
required: string[] | null;
|
||||||
|
}) {
|
||||||
|
const availableNames = new Set((available ?? []).map(z => z.name));
|
||||||
|
const requiredNames = required ?? [];
|
||||||
|
|
||||||
|
// Merge: all required zones (checked/unchecked) + any extra available zones not in required
|
||||||
|
const allZones: { name: string; covered: boolean; needed: boolean }[] = [];
|
||||||
|
for (const name of requiredNames) {
|
||||||
|
allZones.push({ name, covered: availableNames.has(name), needed: true });
|
||||||
|
}
|
||||||
|
for (const z of available ?? []) {
|
||||||
|
if (!requiredNames.includes(z.name)) {
|
||||||
|
allZones.push({ name: z.name, covered: true, needed: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allZones.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
<div>
|
||||||
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Zones</div>
|
||||||
{name}
|
<div className="space-y-1">
|
||||||
</span>
|
{allZones.map((z) => (
|
||||||
|
<div key={z.name} className="flex items-center gap-2 text-xs">
|
||||||
|
{z.needed ? (
|
||||||
|
z.covered ? (
|
||||||
|
<CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="h-3 w-3 text-muted-foreground/40 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono">{z.name}</span>
|
||||||
|
{!z.covered && z.needed && (
|
||||||
|
<span className="text-red-500">not covered</span>
|
||||||
|
)}
|
||||||
|
{!z.needed && (
|
||||||
|
<span className="text-muted-foreground">unused</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionRow({ label, description, ok }: { label: string; description: string; ok?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{ok ? (
|
||||||
|
<CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono font-medium">{label}</span>
|
||||||
|
<span className="text-muted-foreground">{description}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceCard({ label, daemon, icon: Icon, active, version, loading }: {
|
||||||
|
label: string;
|
||||||
|
daemon: string;
|
||||||
|
icon: typeof Globe;
|
||||||
|
active?: boolean;
|
||||||
|
version?: string;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className={`p-4 transition-colors ${active === false ? 'border-red-500/40' : ''}`}>
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<div className={`p-2.5 rounded-xl ${
|
||||||
|
loading ? 'bg-muted' :
|
||||||
|
active ? 'bg-green-500/10' :
|
||||||
|
active === false ? 'bg-red-500/10' :
|
||||||
|
'bg-muted'
|
||||||
|
}`}>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Icon className={`h-5 w-5 ${
|
||||||
|
active ? 'text-green-600 dark:text-green-400' :
|
||||||
|
active === false ? 'text-red-500' :
|
||||||
|
'text-muted-foreground'
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{label}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground font-mono">{daemon}</div>
|
||||||
|
{version && <div className="text-[10px] text-muted-foreground font-mono">{version}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,6 +386,16 @@ export function FirewallComponent() {
|
|||||||
permissive (safe default while you're getting started).
|
permissive (safe default while you're getting started).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{currentWan && !currentExtraPorts.some((p) => p.port === 22) && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
Strict mode is active but SSH (port 22) is not in your allowed ports.
|
||||||
|
You may lose remote access if you don't have another way in.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{!editingWan ? (
|
{!editingWan ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{currentWan ? (
|
{currentWan ? (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiClient } from '../services/api/client';
|
|||||||
|
|
||||||
interface DaemonStatus {
|
interface DaemonStatus {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CentralStatus {
|
interface CentralStatus {
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ export interface CloudflareZone {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CloudflarePermissions {
|
||||||
|
zone: boolean;
|
||||||
|
dns: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CloudflareVerifyResponse {
|
export interface CloudflareVerifyResponse {
|
||||||
tokenConfigured: boolean;
|
tokenConfigured: boolean;
|
||||||
tokenValid: boolean;
|
tokenValid: boolean;
|
||||||
tokenStatus: string;
|
tokenStatus: string;
|
||||||
zones: CloudflareZone[] | null;
|
zones: CloudflareZone[] | null;
|
||||||
|
requiredZones: string[] | null;
|
||||||
|
permissions: CloudflarePermissions;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export interface CrowdSecDecision {
|
|||||||
value: string;
|
value: string;
|
||||||
type: string;
|
type: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
reason: string;
|
scenario: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user