'use client'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Copy, ExternalLink, Loader2, RefreshCw, RotateCw, Save, Server, } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import { NodeService, OptionService, StatusService, UptimeKumaService, } from '@/lib/services/openflare'; import { agentOptionEntries, buildDiscoveryCommand, defaultOpenFlareOpsFields, formatDurationLabel, getBrowserOrigin, mapOptionsToOpsFields, type OpenFlareOpsFields, optionsToMap, pagesOptionEntries, uptimeKumaOptionEntries, } from './openflare-ops-utils'; import { UptimeKumaSiteSelectModal } from './uptimekuma-site-modal'; const optionsQueryKey = ['openflare', 'options'] as const; const openflarePublicStatusQueryKey = ['openflare', 'public-status'] as const; async function copyText(value: string) { await navigator.clipboard.writeText(value); } export function OpenFlareOpsSettings() { const t = useTranslations('openflareOps'); const queryClient = useQueryClient(); const [fields, setFields] = useState( defaultOpenFlareOpsFields, ); const [savingSection, setSavingSection] = useState(null); const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8'); const [uptimeKumaModalOpen, setUptimeKumaModalOpen] = useState(false); const optionsQuery = useQuery({ queryKey: optionsQueryKey, queryFn: () => OptionService.list(), }); const statusQuery = useQuery({ queryKey: openflarePublicStatusQueryKey, queryFn: () => StatusService.getPublicStatus(), }); const bootstrapQuery = useQuery({ queryKey: ['openflare', 'bootstrap-token'], queryFn: () => NodeService.getBootstrapToken(), }); useEffect(() => { if (!optionsQuery.data) return; const optionMap = optionsToMap(optionsQuery.data); const serverAddress = optionMap.server_address || statusQuery.data?.server_address || getBrowserOrigin(); setFields(mapOptionsToOpsFields(optionMap, serverAddress)); }, [optionsQuery.data, statusQuery.data?.server_address]); const geoIPMutation = useMutation({ mutationFn: () => OptionService.lookupGeoIP(fields.geoip_provider, geoIPTestIP.trim()), }); const saveMutation = useMutation({ mutationFn: async ({ section, entries, }: { section: string; entries: Array<{ key: string; value: string }>; }) => { setSavingSection(section); await OptionService.updateBatch(entries); }, onSuccess: async () => { toast.success(t('saved')); await queryClient.invalidateQueries({ queryKey: optionsQueryKey }); setSavingSection(null); }, onError: (error) => { setSavingSection(null); toast.error(error instanceof Error ? error.message : t('saveFailed')); }, }); const rotateTokenMutation = useMutation({ mutationFn: () => NodeService.rotateBootstrapToken(), onSuccess: async (data) => { toast.success(t('tokenRotated')); await queryClient.invalidateQueries({ queryKey: ['openflare', 'bootstrap-token'], }); if (data.discovery_token) { try { await copyText(data.discovery_token); } catch { // ignore clipboard errors } } }, onError: (error) => { toast.error( error instanceof Error ? error.message : t('tokenRotateFailed'), ); }, }); const syncUptimeKumaMutation = useMutation({ mutationFn: () => UptimeKumaService.sync(), onSuccess: () => toast.success(t('kumaSynced')), onError: (error) => toast.error(error instanceof Error ? error.message : t('syncFailed')), }); const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; const discoveryCommand = useMemo(() => { if (!fields.server_address || !discoveryToken) return ''; return buildDiscoveryCommand(fields.server_address, discoveryToken); }, [discoveryToken, fields.server_address]); const updateField = ( key: K, value: OpenFlareOpsFields[K], ) => { setFields((previous) => ({ ...previous, [key]: value })); }; const saveAgentSettings = () => { try { saveMutation.mutate({ section: 'agent', entries: agentOptionEntries(fields, t), }); } catch (error) { toast.error(error instanceof Error ? error.message : t('invalidParams')); } }; const saveUptimeKumaSettings = () => { try { saveMutation.mutate({ section: 'uptimekuma', entries: uptimeKumaOptionEntries(fields, t), }); } catch (error) { toast.error(error instanceof Error ? error.message : t('invalidParams')); } }; const savePagesSettings = () => { try { saveMutation.mutate({ section: 'pages', entries: pagesOptionEntries(fields, t), }); } catch (error) { toast.error(error instanceof Error ? error.message : t('invalidParams')); } }; if (optionsQuery.isLoading) { return ( ); } if (optionsQuery.isError) { return ( void optionsQuery.refetch()} /> ); } return (
{t('agent.title')} {t('agent.description')}
updateField('agent_heartbeat_interval', value) } /> updateField('node_offline_threshold', value) } />
updateField('agent_websocket_upgrade_enabled', value) } /> updateField('agent_update_repo', value)} />
{t('geo.title')} {t('geo.description')}
{geoIPMutation.data ? (
) : null}
{t('pages.title')} {t('pages.description')}
updateField('pages_max_package_size_mb', value) } placeholder='100' /> updateField('pages_max_history_count', value) } placeholder='20' />

{t('pages.historyHint')}

{t('discovery.title')} {t('discovery.description')}
updateField('server_address', value)} placeholder='https://yourdomain.com' />

{t('discovery.tokenReadonly')}

{bootstrapQuery.isLoading ? t('discovery.loading') : discoveryToken || t('discovery.notGenerated')}