First
Some checks failed
Build All Docker Images / changes (push) Has been cancelled
Build and Push App Docker Image / build (push) Has been cancelled
Build and Push Node Docker Image / build (push) Has been cancelled
Test and Lint / test-app (push) Has been cancelled
Test and Lint / test-node (push) Has been cancelled
Test and Lint / lint-dockerfiles (push) Has been cancelled
Test and Lint / security-scan (push) Has been cancelled
Build All Docker Images / build-app (push) Has been cancelled
Build All Docker Images / build-node (push) Has been cancelled
Build All Docker Images / summary (push) Has been cancelled
Some checks failed
Build All Docker Images / changes (push) Has been cancelled
Build and Push App Docker Image / build (push) Has been cancelled
Build and Push Node Docker Image / build (push) Has been cancelled
Test and Lint / test-app (push) Has been cancelled
Test and Lint / test-node (push) Has been cancelled
Test and Lint / lint-dockerfiles (push) Has been cancelled
Test and Lint / security-scan (push) Has been cancelled
Build All Docker Images / build-app (push) Has been cancelled
Build All Docker Images / build-node (push) Has been cancelled
Build All Docker Images / summary (push) Has been cancelled
This commit is contained in:
commit
4169337dd0
68 changed files with 8726 additions and 0 deletions
213
app/src/client/pages/Dashboard.tsx
Normal file
213
app/src/client/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { tunnelApi, frpcApi, nodeApi } from '../api/client';
|
||||
import { CheckCircle, XCircle, AlertCircle, RefreshCw, Wifi, WifiOff } from 'lucide-react';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { data: tunnels, isLoading: tunnelsLoading } = useQuery({
|
||||
queryKey: ['tunnels'],
|
||||
queryFn: tunnelApi.getAllTunnels,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: tunnelStatuses, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['tunnel-statuses'],
|
||||
queryFn: tunnelApi.getAllTunnelStatuses,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: frpcStatus, isLoading: frpcLoading } = useQuery({
|
||||
queryKey: ['frpc-status'],
|
||||
queryFn: frpcApi.getStatus,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: nodeConnection } = useQuery({
|
||||
queryKey: ['node-connection'],
|
||||
queryFn: nodeApi.getConnection,
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: nodeStatus } = useQuery({
|
||||
queryKey: ['node-status'],
|
||||
queryFn: nodeApi.getStatus,
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
enabled: !!nodeConnection?.isReachable,
|
||||
});
|
||||
|
||||
const activeTunnels = tunnels?.filter(t => t.enabled) || [];
|
||||
const activeTunnelStatuses = tunnelStatuses?.filter(s => s.active) || [];
|
||||
|
||||
if (tunnelsLoading || statusLoading || frpcLoading) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="loading">
|
||||
<RefreshCw className="animate-spin" size={24} />
|
||||
<span>Loading dashboard...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h1>FRP Manager Dashboard</h1>
|
||||
<p>Manage your tunnel configurations and monitor their status</p>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<CheckCircle className="text-green-500" size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>Active Tunnels</h3>
|
||||
<p className="stat-number">{activeTunnelStatuses.length}</p>
|
||||
<p className="stat-description">Currently running</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<AlertCircle className="text-yellow-500" size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>Total Tunnels</h3>
|
||||
<p className="stat-number">{tunnels?.length || 0}</p>
|
||||
<p className="stat-description">Configured tunnels</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
{frpcStatus?.running ? (
|
||||
<CheckCircle className="text-green-500" size={24} />
|
||||
) : (
|
||||
<XCircle className="text-red-500" size={24} />
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>FRPC Service</h3>
|
||||
<p className="stat-number">{frpcStatus?.running ? 'Running' : 'Stopped'}</p>
|
||||
<p className="stat-description">Service status</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
<CheckCircle className="text-blue-500" size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>Enabled Tunnels</h3>
|
||||
<p className="stat-number">{activeTunnels.length}</p>
|
||||
<p className="stat-description">Ready to connect</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">
|
||||
{nodeConnection?.isReachable ? (
|
||||
<Wifi className="text-green-500" size={24} />
|
||||
) : (
|
||||
<WifiOff className="text-red-500" size={24} />
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>Node Status</h3>
|
||||
<p className="stat-number">{nodeConnection?.isReachable ? 'Online' : 'Offline'}</p>
|
||||
<p className="stat-description">Home server agent</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-sections">
|
||||
<div className="section">
|
||||
<h2>Recent Tunnels</h2>
|
||||
<div className="tunnel-list">
|
||||
{activeTunnels.slice(0, 5).map(tunnel => (
|
||||
<div key={tunnel.id} className="tunnel-item">
|
||||
<div className="tunnel-info">
|
||||
<h4>{tunnel.name}</h4>
|
||||
<p>{tunnel.protocol} • {tunnel.localIp}:{tunnel.localPort} → :{tunnel.remotePort}</p>
|
||||
</div>
|
||||
<div className="tunnel-status">
|
||||
{tunnelStatuses?.find(s => s.id === tunnel.id)?.active ? (
|
||||
<span className="status-badge status-active">Active</span>
|
||||
) : (
|
||||
<span className="status-badge status-inactive">Inactive</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activeTunnels.length === 0 && (
|
||||
<p className="empty-state">No active tunnels configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h2>System Status</h2>
|
||||
<div className="status-list">
|
||||
<div className="status-item">
|
||||
<div className="status-indicator">
|
||||
{frpcStatus?.running ? (
|
||||
<CheckCircle className="text-green-500" size={20} />
|
||||
) : (
|
||||
<XCircle className="text-red-500" size={20} />
|
||||
)}
|
||||
</div>
|
||||
<div className="status-content">
|
||||
<h4>FRPC Service</h4>
|
||||
<p>{frpcStatus?.running ? 'Service is running normally' : 'Service is stopped'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-item">
|
||||
<div className="status-indicator">
|
||||
<CheckCircle className="text-green-500" size={20} />
|
||||
</div>
|
||||
<div className="status-content">
|
||||
<h4>API Server</h4>
|
||||
<p>API is responding normally</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-item">
|
||||
<div className="status-indicator">
|
||||
<CheckCircle className="text-green-500" size={20} />
|
||||
</div>
|
||||
<div className="status-content">
|
||||
<h4>Database</h4>
|
||||
<p>Database connection is healthy</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-item">
|
||||
<div className="status-indicator">
|
||||
{nodeConnection?.isReachable ? (
|
||||
<Wifi className="text-green-500" size={20} />
|
||||
) : (
|
||||
<WifiOff className="text-red-500" size={20} />
|
||||
)}
|
||||
</div>
|
||||
<div className="status-content">
|
||||
<h4>Home Server Node</h4>
|
||||
<p>
|
||||
{nodeConnection?.isReachable
|
||||
? `Connected • Last seen: ${nodeConnection.lastConnectionTime ? new Date(nodeConnection.lastConnectionTime).toLocaleTimeString() : 'Now'}`
|
||||
: 'Disconnected or unreachable'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
253
app/src/client/pages/ServerStatus.tsx
Normal file
253
app/src/client/pages/ServerStatus.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { frpcApi } from '../api/client';
|
||||
import { Server, Play, Square, RotateCcw, RefreshCw, FileText, Activity } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const ServerStatus: React.FC = () => {
|
||||
const [logsLines, setLogsLines] = useState(50);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: frpcStatus, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['frpc-status'],
|
||||
queryFn: frpcApi.getStatus,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const { data: logs, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['frpc-logs', logsLines],
|
||||
queryFn: () => frpcApi.getLogs(logsLines),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: frpcApi.start,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['frpc-status'] });
|
||||
toast.success('FRPC service started');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Start error:', error);
|
||||
toast.error('Failed to start FRPC service');
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: frpcApi.stop,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['frpc-status'] });
|
||||
toast.success('FRPC service stopped');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Stop error:', error);
|
||||
toast.error('Failed to stop FRPC service');
|
||||
},
|
||||
});
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: frpcApi.restart,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['frpc-status'] });
|
||||
toast.success('FRPC service restarted');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Restart error:', error);
|
||||
toast.error('Failed to restart FRPC service');
|
||||
},
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: frpcApi.regenerate,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['frpc-status'] });
|
||||
toast.success('FRPC configuration regenerated');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Regenerate error:', error);
|
||||
toast.error('Failed to regenerate FRPC configuration');
|
||||
},
|
||||
});
|
||||
|
||||
const handleStart = () => startMutation.mutate();
|
||||
const handleStop = () => stopMutation.mutate();
|
||||
const handleRestart = () => restartMutation.mutate();
|
||||
const handleRegenerate = () => regenerateMutation.mutate();
|
||||
|
||||
const isLoading = statusLoading || logsLoading;
|
||||
const isAnyMutationPending =
|
||||
startMutation.isPending ||
|
||||
stopMutation.isPending ||
|
||||
restartMutation.isPending ||
|
||||
regenerateMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="server-status">
|
||||
<div className="server-header">
|
||||
<h1>Server Status</h1>
|
||||
<div className="server-info">
|
||||
<div className="status-indicator">
|
||||
<Activity
|
||||
size={24}
|
||||
className={frpcStatus?.running ? 'text-green-500' : 'text-red-500'}
|
||||
/>
|
||||
<span className={`status-text ${frpcStatus?.running ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{frpcStatus?.running ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="server-controls">
|
||||
<div className="control-section">
|
||||
<h2>Service Controls</h2>
|
||||
<div className="control-buttons">
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={handleStart}
|
||||
disabled={isAnyMutationPending || frpcStatus?.running}
|
||||
>
|
||||
<Play size={18} />
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleStop}
|
||||
disabled={isAnyMutationPending || !frpcStatus?.running}
|
||||
>
|
||||
<Square size={18} />
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-warning"
|
||||
onClick={handleRestart}
|
||||
disabled={isAnyMutationPending}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleRegenerate}
|
||||
disabled={isAnyMutationPending}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
Regenerate Config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-section">
|
||||
<h2>Service Information</h2>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<span className="info-label">Status:</span>
|
||||
<span className={`info-value ${frpcStatus?.running ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{frpcStatus?.running ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Container:</span>
|
||||
<span className="info-value">frpc</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Last Updated:</span>
|
||||
<span className="info-value">
|
||||
{new Date().toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="logs-section">
|
||||
<div className="logs-header">
|
||||
<h2>
|
||||
<FileText size={20} />
|
||||
Service Logs
|
||||
</h2>
|
||||
<div className="logs-controls">
|
||||
<select
|
||||
value={logsLines}
|
||||
onChange={(e) => setLogsLines(parseInt(e.target.value))}
|
||||
className="logs-select"
|
||||
>
|
||||
<option value={25}>Last 25 lines</option>
|
||||
<option value={50}>Last 50 lines</option>
|
||||
<option value={100}>Last 100 lines</option>
|
||||
<option value={200}>Last 200 lines</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['frpc-logs'] })}
|
||||
disabled={logsLoading}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="logs-container">
|
||||
{logsLoading ? (
|
||||
<div className="logs-loading">
|
||||
<RefreshCw className="animate-spin" size={20} />
|
||||
<span>Loading logs...</span>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="logs-content">
|
||||
{logs?.logs || 'No logs available'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="system-info">
|
||||
<h2>System Information</h2>
|
||||
<div className="info-cards">
|
||||
<div className="info-card">
|
||||
<div className="info-card-header">
|
||||
<Server size={24} />
|
||||
<h3>FRPC Service</h3>
|
||||
</div>
|
||||
<div className="info-card-content">
|
||||
<p>Fast Reverse Proxy Client for tunneling services</p>
|
||||
<p className="info-detail">
|
||||
Status: <span className={frpcStatus?.running ? 'text-green-500' : 'text-red-500'}>
|
||||
{frpcStatus?.running ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<div className="info-card-header">
|
||||
<Activity size={24} />
|
||||
<h3>API Server</h3>
|
||||
</div>
|
||||
<div className="info-card-content">
|
||||
<p>RESTful API for managing tunnel configurations</p>
|
||||
<p className="info-detail">
|
||||
Status: <span className="text-green-500">Running</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<div className="info-card-header">
|
||||
<FileText size={24} />
|
||||
<h3>Configuration</h3>
|
||||
</div>
|
||||
<div className="info-card-content">
|
||||
<p>Tunnel configurations stored in SQLite database</p>
|
||||
<p className="info-detail">
|
||||
Auto-generated FRPC config from active tunnels
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerStatus;
|
||||
244
app/src/client/pages/TunnelManager.tsx
Normal file
244
app/src/client/pages/TunnelManager.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { tunnelApi, nodeApi, TunnelConfig } from '../api/client';
|
||||
import { Plus, Edit2, Trash2, Power, PowerOff, Settings, Send, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import TunnelForm from '../components/TunnelForm';
|
||||
|
||||
const TunnelManager: React.FC = () => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingTunnel, setEditingTunnel] = useState<TunnelConfig | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: tunnels, isLoading } = useQuery({
|
||||
queryKey: ['tunnels'],
|
||||
queryFn: tunnelApi.getAllTunnels,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: tunnelStatuses } = useQuery({
|
||||
queryKey: ['tunnel-statuses'],
|
||||
queryFn: tunnelApi.getAllTunnelStatuses,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: nodeConnection } = useQuery({
|
||||
queryKey: ['node-connection'],
|
||||
queryFn: nodeApi.getConnection,
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: tunnelApi.deleteTunnel,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tunnels'] });
|
||||
toast.success('Tunnel deleted successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Delete error:', error);
|
||||
toast.error('Failed to delete tunnel');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, updates }: { id: string; updates: Partial<TunnelConfig> }) =>
|
||||
tunnelApi.updateTunnel(id, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tunnels'] });
|
||||
toast.success('Tunnel updated successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Update error:', error);
|
||||
toast.error('Failed to update tunnel');
|
||||
},
|
||||
});
|
||||
|
||||
const pushToNodeMutation = useMutation({
|
||||
mutationFn: nodeApi.pushAndRestart,
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Successfully pushed ${data.tunnelCount} tunnels to node and restarted frpc`);
|
||||
queryClient.invalidateQueries({ queryKey: ['node-connection'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Push to node error:', error);
|
||||
toast.error(error.response?.data?.error || 'Failed to push configuration to node');
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this tunnel?')) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (tunnel: TunnelConfig) => {
|
||||
setEditingTunnel(tunnel);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (tunnel: TunnelConfig) => {
|
||||
updateMutation.mutate({
|
||||
id: tunnel.id!,
|
||||
updates: { enabled: !tunnel.enabled },
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormClose = () => {
|
||||
setShowForm(false);
|
||||
setEditingTunnel(null);
|
||||
};
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
handleFormClose();
|
||||
queryClient.invalidateQueries({ queryKey: ['tunnels'] });
|
||||
};
|
||||
|
||||
const handlePushToNode = () => {
|
||||
if (window.confirm('Push current tunnel configuration to node and restart frpc?')) {
|
||||
pushToNodeMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="tunnel-manager">
|
||||
<div className="loading">Loading tunnels...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tunnel-manager">
|
||||
<div className="tunnel-header">
|
||||
<h1>Tunnel Manager</h1>
|
||||
<div className="header-actions">
|
||||
{nodeConnection && (
|
||||
<div className="node-status">
|
||||
<span className={`node-indicator ${nodeConnection.isReachable ? 'online' : 'offline'}`}>
|
||||
Node: {nodeConnection.isReachable ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handlePushToNode}
|
||||
disabled={pushToNodeMutation.isPending || !nodeConnection.isReachable}
|
||||
title="Push configuration to node and restart frpc"
|
||||
>
|
||||
{pushToNodeMutation.isPending ? (
|
||||
<RefreshCw size={18} className="spinning" />
|
||||
) : (
|
||||
<Send size={18} />
|
||||
)}
|
||||
Push to Node
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Tunnel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tunnels-grid">
|
||||
{tunnels?.map(tunnel => {
|
||||
const status = tunnelStatuses?.find(s => s.id === tunnel.id);
|
||||
return (
|
||||
<div key={tunnel.id} className="tunnel-card">
|
||||
<div className="tunnel-card-header">
|
||||
<div className="tunnel-title">
|
||||
<h3>{tunnel.name}</h3>
|
||||
<div className="tunnel-badges">
|
||||
<span className={`badge ${tunnel.enabled ? 'badge-success' : 'badge-secondary'}`}>
|
||||
{tunnel.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<span className={`badge ${status?.active ? 'badge-active' : 'badge-inactive'}`}>
|
||||
{status?.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tunnel-actions">
|
||||
<button
|
||||
className={`btn btn-sm ${tunnel.enabled ? 'btn-warning' : 'btn-success'}`}
|
||||
onClick={() => handleToggleEnabled(tunnel)}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{tunnel.enabled ? <PowerOff size={16} /> : <Power size={16} />}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleEdit(tunnel)}
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(tunnel.id!)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tunnel-info">
|
||||
<div className="info-row">
|
||||
<span className="info-label">Protocol:</span>
|
||||
<span className="info-value">{tunnel.protocol}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Local:</span>
|
||||
<span className="info-value">{tunnel.localIp}:{tunnel.localPort}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Remote:</span>
|
||||
<span className="info-value">:{tunnel.remotePort}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Created:</span>
|
||||
<span className="info-value">
|
||||
{tunnel.createdAt ? new Date(tunnel.createdAt).toLocaleDateString() : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.error && (
|
||||
<div className="tunnel-error">
|
||||
<span className="error-text">Error: {status.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tunnels?.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<Settings size={64} className="empty-icon" />
|
||||
<h3>No tunnels configured</h3>
|
||||
<p>Get started by adding your first tunnel configuration</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Your First Tunnel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<TunnelForm
|
||||
tunnel={editingTunnel}
|
||||
onClose={handleFormClose}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TunnelManager;
|
||||
Loading…
Add table
Add a link
Reference in a new issue