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
922
app/src/client/App.css
Normal file
922
app/src/client/App.css
Normal file
|
|
@ -0,0 +1,922 @@
|
|||
/* Base styles */
|
||||
:root {
|
||||
--primary: #0066cc;
|
||||
--primary-hover: #0052a3;
|
||||
--secondary: #6c757d;
|
||||
--success: #28a745;
|
||||
--danger: #dc3545;
|
||||
--warning: #ffc107;
|
||||
--info: #17a2b8;
|
||||
--light: #f8f9fa;
|
||||
--dark: #343a40;
|
||||
--border: #dee2e6;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.navbar-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary);
|
||||
border-color: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success);
|
||||
border-color: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--warning);
|
||||
border-color: var(--warning);
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-content h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-sections {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tunnel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tunnel-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tunnel-info h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tunnel-info p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-content h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.status-content p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Tunnel Manager */
|
||||
.tunnel-manager {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tunnel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tunnel-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.node-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.node-indicator {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.node-indicator.online {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.node-indicator.offline {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tunnels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.tunnel-card {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.tunnel-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tunnel-title h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tunnel-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tunnel-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tunnel-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tunnel-error {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Form overlay */
|
||||
.form-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.form-modal {
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: 0.5rem;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tunnel-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Server Status */
|
||||
.server-status {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.server-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.server-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.control-section {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.control-section h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.status-section h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.logs-section {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.logs-header h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logs-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logs-select {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
background-color: var(--dark);
|
||||
color: var(--light);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.logs-content {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.logs-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* System info */
|
||||
.system-info {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.system-info h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.info-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-card-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-card-content p {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-detail {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.text-green-500 {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.text-red-500 {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.text-yellow-500 {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.text-blue-500 {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.server-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tunnels-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-modal {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tunnel-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
51
app/src/client/App.tsx
Normal file
51
app/src/client/App.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import Navbar from './components/Navbar';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import TunnelManager from './pages/TunnelManager';
|
||||
import ServerStatus from './pages/ServerStatus';
|
||||
import './App.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 5 * 1000, // 5 seconds
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
<div className="App">
|
||||
<Navbar />
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/tunnels" element={<TunnelManager />} />
|
||||
<Route path="/status" element={<ServerStatus />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Router>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
199
app/src/client/api/client.ts
Normal file
199
app/src/client/api/client.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import axios from 'axios';
|
||||
|
||||
export interface TunnelConfig {
|
||||
id?: string;
|
||||
name: string;
|
||||
protocol: 'TCP' | 'UDP';
|
||||
localIp: string;
|
||||
localPort: number;
|
||||
remotePort: number;
|
||||
enabled: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TunnelStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
lastChecked: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FrpcStatus {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface FrpcLogs {
|
||||
logs: string;
|
||||
}
|
||||
|
||||
export interface NodeStatus {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
uptime?: number;
|
||||
memory?: {
|
||||
used: number;
|
||||
total: number;
|
||||
};
|
||||
cpu?: {
|
||||
usage: number;
|
||||
};
|
||||
connection?: {
|
||||
url: string;
|
||||
isOnline: boolean;
|
||||
lastConnectionTime: Date | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeConnection {
|
||||
url: string;
|
||||
isOnline: boolean;
|
||||
lastConnectionTime: Date | null;
|
||||
isReachable: boolean;
|
||||
}
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Request interceptor for logging
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`API Request: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('API Request Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('API Response Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const tunnelApi = {
|
||||
// Get all tunnels
|
||||
getAllTunnels: async (): Promise<TunnelConfig[]> => {
|
||||
const response = await apiClient.get('/tunnels');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get tunnel by ID
|
||||
getTunnelById: async (id: string): Promise<TunnelConfig> => {
|
||||
const response = await apiClient.get(`/tunnels/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create new tunnel
|
||||
createTunnel: async (tunnel: Omit<TunnelConfig, 'id' | 'createdAt' | 'updatedAt'>): Promise<TunnelConfig> => {
|
||||
const response = await apiClient.post('/tunnels', tunnel);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update tunnel
|
||||
updateTunnel: async (id: string, tunnel: Partial<TunnelConfig>): Promise<TunnelConfig> => {
|
||||
const response = await apiClient.put(`/tunnels/${id}`, tunnel);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete tunnel
|
||||
deleteTunnel: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/tunnels/${id}`);
|
||||
},
|
||||
|
||||
// Get tunnel status
|
||||
getTunnelStatus: async (id: string): Promise<TunnelStatus> => {
|
||||
const response = await apiClient.get(`/tunnels/${id}/status`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all tunnel statuses
|
||||
getAllTunnelStatuses: async (): Promise<TunnelStatus[]> => {
|
||||
const response = await apiClient.get('/tunnels-status');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const frpcApi = {
|
||||
// Get frpc status
|
||||
getStatus: async (): Promise<FrpcStatus> => {
|
||||
const response = await apiClient.get('/frpc/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Control frpc service
|
||||
start: async (): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/frpc/start');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
stop: async (): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/frpc/stop');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
restart: async (): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/frpc/restart');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
regenerate: async (): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/frpc/regenerate');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get frpc logs
|
||||
getLogs: async (lines: number = 50): Promise<FrpcLogs> => {
|
||||
const response = await apiClient.get(`/frpc/logs?lines=${lines}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const nodeApi = {
|
||||
// Get node status
|
||||
getStatus: async (): Promise<NodeStatus> => {
|
||||
const response = await apiClient.get('/node/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get node connection info
|
||||
getConnection: async (): Promise<NodeConnection> => {
|
||||
const response = await apiClient.get('/node/connection');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Push configuration to node
|
||||
pushConfig: async (): Promise<{ message: string; tunnelCount: number; nodeResponse: any }> => {
|
||||
const response = await apiClient.post('/node/push-config');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Restart frpc on node
|
||||
restartFrpc: async (): Promise<{ message: string; nodeResponse: any }> => {
|
||||
const response = await apiClient.post('/node/restart-frpc');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Push config and restart frpc on node
|
||||
pushAndRestart: async (): Promise<{
|
||||
message: string;
|
||||
tunnelCount: number;
|
||||
configResponse: any;
|
||||
restartResponse: any
|
||||
}> => {
|
||||
const response = await apiClient.post('/node/push-and-restart');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
1
app/src/client/assets/react.svg
Normal file
1
app/src/client/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
43
app/src/client/components/Navbar.tsx
Normal file
43
app/src/client/components/Navbar.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Server, Settings, Activity } from 'lucide-react';
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="navbar-brand">
|
||||
<Server className="navbar-icon" />
|
||||
<span>FRP Manager</span>
|
||||
</div>
|
||||
<div className="navbar-nav">
|
||||
<Link
|
||||
to="/"
|
||||
className={`nav-link ${isActive('/') ? 'active' : ''}`}
|
||||
>
|
||||
<Activity size={18} />
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/tunnels"
|
||||
className={`nav-link ${isActive('/tunnels') ? 'active' : ''}`}
|
||||
>
|
||||
<Settings size={18} />
|
||||
Tunnels
|
||||
</Link>
|
||||
<Link
|
||||
to="/status"
|
||||
className={`nav-link ${isActive('/status') ? 'active' : ''}`}
|
||||
>
|
||||
<Server size={18} />
|
||||
Server Status
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
232
app/src/client/components/TunnelForm.tsx
Normal file
232
app/src/client/components/TunnelForm.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { tunnelApi, TunnelConfig } from '../api/client';
|
||||
import { X, Save, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface TunnelFormProps {
|
||||
tunnel?: TunnelConfig | null;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
const TunnelForm: React.FC<TunnelFormProps> = ({ tunnel, onClose, onSubmit }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = Boolean(tunnel);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: tunnel?.name || '',
|
||||
protocol: tunnel?.protocol || 'TCP' as 'TCP' | 'UDP',
|
||||
localIp: tunnel?.localIp || '127.0.0.1',
|
||||
localPort: tunnel?.localPort || 8080,
|
||||
remotePort: tunnel?.remotePort || 8080,
|
||||
enabled: tunnel?.enabled ?? true,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: tunnelApi.createTunnel,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tunnels'] });
|
||||
toast.success('Tunnel created successfully');
|
||||
onSubmit();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Create error:', error);
|
||||
toast.error('Failed to create 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');
|
||||
onSubmit();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Update error:', error);
|
||||
toast.error('Failed to update tunnel');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Please enter a name');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.localPort < 1 || formData.localPort > 65535) {
|
||||
toast.error('Local port must be between 1 and 65535');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.remotePort < 1 || formData.remotePort > 65535) {
|
||||
toast.error('Remote port must be between 1 and 65535');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditing && tunnel) {
|
||||
updateMutation.mutate({
|
||||
id: tunnel.id!,
|
||||
updates: formData,
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: checked,
|
||||
}));
|
||||
} else if (type === 'number') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: parseInt(value) || 0,
|
||||
}));
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="form-overlay">
|
||||
<div className="form-modal">
|
||||
<div className="form-header">
|
||||
<h2>{isEditing ? 'Edit Tunnel' : 'Create New Tunnel'}</h2>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="tunnel-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., Minecraft Server"
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="protocol">Protocol</label>
|
||||
<select
|
||||
id="protocol"
|
||||
name="protocol"
|
||||
value={formData.protocol}
|
||||
onChange={handleChange}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="localIp">Local IP</label>
|
||||
<input
|
||||
type="text"
|
||||
id="localIp"
|
||||
name="localIp"
|
||||
value={formData.localIp}
|
||||
onChange={handleChange}
|
||||
placeholder="127.0.0.1"
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="localPort">Local Port</label>
|
||||
<input
|
||||
type="number"
|
||||
id="localPort"
|
||||
name="localPort"
|
||||
value={formData.localPort}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
max="65535"
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="remotePort">Remote Port</label>
|
||||
<input
|
||||
type="number"
|
||||
id="remotePort"
|
||||
name="remotePort"
|
||||
value={formData.remotePort}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
max="65535"
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
checked={formData.enabled}
|
||||
onChange={handleChange}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<span>Enable this tunnel</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="animate-spin" size={16} />}
|
||||
<Save size={16} />
|
||||
{isEditing ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TunnelForm;
|
||||
69
app/src/client/index.css
Normal file
69
app/src/client/index.css
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
12
app/src/client/main.tsx
Normal file
12
app/src/client/main.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import "./index.css";
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
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;
|
||||
8
app/src/client/tsconfig.json
Normal file
8
app/src/client/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
1
app/src/client/vite-env.d.ts
vendored
Normal file
1
app/src/client/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
179
app/src/server/database.ts
Normal file
179
app/src/server/database.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { TunnelConfig, TunnelConfigSchema, TunnelConfigUpdate } from './types.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import path from 'path';
|
||||
|
||||
export class TunnelDatabase {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(dbPath: string = 'data/tunnels.db') {
|
||||
this.db = new Database(dbPath);
|
||||
this.initializeDatabase();
|
||||
}
|
||||
|
||||
private initializeDatabase() {
|
||||
// Create tunnels table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tunnels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
protocol TEXT NOT NULL CHECK(protocol IN ('TCP', 'UDP')),
|
||||
local_ip TEXT NOT NULL,
|
||||
local_port INTEGER NOT NULL,
|
||||
remote_port INTEGER NOT NULL,
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create trigger to update updated_at column
|
||||
this.db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS update_tunnels_updated_at
|
||||
AFTER UPDATE ON tunnels
|
||||
BEGIN
|
||||
UPDATE tunnels SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END
|
||||
`);
|
||||
}
|
||||
|
||||
// Get all tunnel configurations
|
||||
getAllTunnels(): TunnelConfig[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
protocol,
|
||||
local_ip as localIp,
|
||||
local_port as localPort,
|
||||
remote_port as remotePort,
|
||||
enabled,
|
||||
created_at as createdAt,
|
||||
updated_at as updatedAt
|
||||
FROM tunnels
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
return stmt.all() as TunnelConfig[];
|
||||
}
|
||||
|
||||
// Get tunnel by ID
|
||||
getTunnelById(id: string): TunnelConfig | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
protocol,
|
||||
local_ip as localIp,
|
||||
local_port as localPort,
|
||||
remote_port as remotePort,
|
||||
enabled,
|
||||
created_at as createdAt,
|
||||
updated_at as updatedAt
|
||||
FROM tunnels
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const result = stmt.get(id) as TunnelConfig | undefined;
|
||||
return result || null;
|
||||
}
|
||||
|
||||
// Create new tunnel configuration
|
||||
createTunnel(config: Omit<TunnelConfig, 'id' | 'createdAt' | 'updatedAt'>): TunnelConfig {
|
||||
const id = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO tunnels (id, name, protocol, local_ip, local_port, remote_port, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(id, config.name, config.protocol, config.localIp, config.localPort, config.remotePort, config.enabled);
|
||||
|
||||
return {
|
||||
id,
|
||||
...config,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
// Update tunnel configuration
|
||||
updateTunnel(id: string, updates: Partial<TunnelConfig>): TunnelConfig | null {
|
||||
const existing = this.getTunnelById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
fields.push('name = ?');
|
||||
values.push(updates.name);
|
||||
}
|
||||
if (updates.protocol !== undefined) {
|
||||
fields.push('protocol = ?');
|
||||
values.push(updates.protocol);
|
||||
}
|
||||
if (updates.localIp !== undefined) {
|
||||
fields.push('local_ip = ?');
|
||||
values.push(updates.localIp);
|
||||
}
|
||||
if (updates.localPort !== undefined) {
|
||||
fields.push('local_port = ?');
|
||||
values.push(updates.localPort);
|
||||
}
|
||||
if (updates.remotePort !== undefined) {
|
||||
fields.push('remote_port = ?');
|
||||
values.push(updates.remotePort);
|
||||
}
|
||||
if (updates.enabled !== undefined) {
|
||||
fields.push('enabled = ?');
|
||||
values.push(updates.enabled);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return existing;
|
||||
|
||||
values.push(id);
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE tunnels
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(...values);
|
||||
return this.getTunnelById(id);
|
||||
}
|
||||
|
||||
// Delete tunnel configuration
|
||||
deleteTunnel(id: string): boolean {
|
||||
const stmt = this.db.prepare('DELETE FROM tunnels WHERE id = ?');
|
||||
const result = stmt.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// Get enabled tunnels only
|
||||
getEnabledTunnels(): TunnelConfig[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
protocol,
|
||||
local_ip as localIp,
|
||||
local_port as localPort,
|
||||
remote_port as remotePort,
|
||||
enabled,
|
||||
created_at as createdAt,
|
||||
updated_at as updatedAt
|
||||
FROM tunnels
|
||||
WHERE enabled = 1
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
return stmt.all() as TunnelConfig[];
|
||||
}
|
||||
|
||||
// Close database connection
|
||||
close() {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
145
app/src/server/frpc-manager.ts
Normal file
145
app/src/server/frpc-manager.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { TunnelConfig, FrpcConfig } from './types.js';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export class FrpcManager {
|
||||
private configPath: string;
|
||||
private containerName: string;
|
||||
|
||||
constructor(configPath: string = 'data/frpc.toml', containerName: string = 'frpc') {
|
||||
this.configPath = configPath;
|
||||
this.containerName = containerName;
|
||||
}
|
||||
|
||||
// Generate frpc.toml configuration from tunnel configs
|
||||
async generateConfig(tunnels: TunnelConfig[], serverAddr: string, serverPort: number = 7000, token?: string): Promise<void> {
|
||||
try {
|
||||
const config: FrpcConfig = {
|
||||
serverAddr,
|
||||
serverPort,
|
||||
token,
|
||||
proxies: {}
|
||||
};
|
||||
|
||||
// Add enabled tunnels to config
|
||||
for (const tunnel of tunnels.filter(t => t.enabled)) {
|
||||
config.proxies[tunnel.name] = {
|
||||
type: tunnel.protocol.toLowerCase() as 'tcp' | 'udp',
|
||||
localIP: tunnel.localIp,
|
||||
localPort: tunnel.localPort,
|
||||
remotePort: tunnel.remotePort
|
||||
};
|
||||
}
|
||||
|
||||
const tomlContent = this.generateTomlContent(config);
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(this.configPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
await fs.writeFile(this.configPath, tomlContent);
|
||||
logger.info(`Generated frpc configuration with ${Object.keys(config.proxies).length} tunnels`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate frpc configuration:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private generateTomlContent(config: FrpcConfig): string {
|
||||
let toml = `[common]
|
||||
server_addr = "${config.serverAddr}"
|
||||
server_port = ${config.serverPort}
|
||||
`;
|
||||
|
||||
if (config.token) {
|
||||
toml += `token = "${config.token}"\n`;
|
||||
}
|
||||
|
||||
toml += '\n';
|
||||
|
||||
// Add proxy configurations
|
||||
for (const [name, proxy] of Object.entries(config.proxies)) {
|
||||
toml += `[${name}]
|
||||
type = "${proxy.type}"
|
||||
local_ip = "${proxy.localIP}"
|
||||
local_port = ${proxy.localPort}
|
||||
remote_port = ${proxy.remotePort}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
return toml;
|
||||
}
|
||||
|
||||
// Check if frpc container is running
|
||||
async isRunning(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`docker ps --filter "name=${this.containerName}" --format "{{.Names}}"`);
|
||||
return stdout.trim() === this.containerName;
|
||||
} catch (error) {
|
||||
logger.error('Failed to check frpc container status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start frpc container
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
await execAsync(`docker start ${this.containerName}`);
|
||||
logger.info(`Started frpc container: ${this.containerName}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to start frpc container:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop frpc container
|
||||
async stop(): Promise<void> {
|
||||
try {
|
||||
await execAsync(`docker stop ${this.containerName}`);
|
||||
logger.info(`Stopped frpc container: ${this.containerName}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop frpc container:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Restart frpc container
|
||||
async restart(): Promise<void> {
|
||||
try {
|
||||
await execAsync(`docker restart ${this.containerName}`);
|
||||
logger.info(`Restarted frpc container: ${this.containerName}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to restart frpc container:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get frpc container logs
|
||||
async getLogs(lines: number = 50): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`docker logs --tail ${lines} ${this.containerName}`);
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get frpc container logs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check tunnel status by attempting to connect
|
||||
async checkTunnelStatus(tunnel: TunnelConfig): Promise<boolean> {
|
||||
try {
|
||||
// This is a basic implementation - you might want to implement actual connectivity checks
|
||||
// For now, we'll just check if the container is running
|
||||
return await this.isRunning();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check tunnel status for ${tunnel.name}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/src/server/logger.ts
Normal file
42
app/src/server/logger.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import winston from 'winston';
|
||||
import path from 'path';
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
const logDir = 'logs';
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: 'frpc-manager' },
|
||||
transports: [
|
||||
// Write all logs with level 'error' and below to error.log
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error',
|
||||
handleExceptions: true,
|
||||
handleRejections: true
|
||||
}),
|
||||
// Write all logs with level 'info' and below to combined.log
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'combined.log'),
|
||||
handleExceptions: true,
|
||||
handleRejections: true
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// If we're not in production, also log to console with simple format
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
export default logger;
|
||||
55
app/src/server/main.ts
Normal file
55
app/src/server/main.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import express from "express";
|
||||
import ViteExpress from "vite-express";
|
||||
import cors from "cors";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { logger } from "./logger.js";
|
||||
import routes from "./routes.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
// Get __dirname in ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Logging middleware
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
userAgent: req.get('User-Agent'),
|
||||
ip: req.ip
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use("/api", routes);
|
||||
|
||||
// Health check endpoint
|
||||
app.get("/health", (_, res) => {
|
||||
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Legacy hello endpoint
|
||||
app.get("/hello", (_, res) => {
|
||||
res.send("Hello Vite + React + TypeScript!");
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((error: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000');
|
||||
|
||||
ViteExpress.listen(app, PORT, () => {
|
||||
logger.info(`Server is listening on port ${PORT}`);
|
||||
console.log(`Server is listening on port ${PORT}...`);
|
||||
});
|
||||
114
app/src/server/node-client.ts
Normal file
114
app/src/server/node-client.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import axios, { AxiosResponse } from 'axios';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export interface NodeStatus {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
uptime?: number;
|
||||
memory?: {
|
||||
used: number;
|
||||
total: number;
|
||||
};
|
||||
cpu?: {
|
||||
usage: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class NodeClient {
|
||||
private config: NodeConfig;
|
||||
private lastConnectionTime: Date | null = null;
|
||||
private isOnline: boolean = false;
|
||||
|
||||
constructor(config: NodeConfig) {
|
||||
this.config = {
|
||||
...config,
|
||||
timeout: config.timeout || 5000,
|
||||
};
|
||||
}
|
||||
|
||||
private async makeRequest<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
endpoint: string,
|
||||
data?: any
|
||||
): Promise<T> {
|
||||
try {
|
||||
const response: AxiosResponse<T> = await axios({
|
||||
method,
|
||||
url: `${this.config.url}${endpoint}`,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.config.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data,
|
||||
timeout: this.config.timeout,
|
||||
});
|
||||
|
||||
this.lastConnectionTime = new Date();
|
||||
this.isOnline = true;
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.isOnline = false;
|
||||
logger.error(`Node request failed: ${method} ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get node status
|
||||
async getStatus(): Promise<NodeStatus> {
|
||||
return this.makeRequest<NodeStatus>('GET', '/api/status');
|
||||
}
|
||||
|
||||
// Send updated frpc.toml config to the node
|
||||
async updateConfig(config: string): Promise<{ success: boolean; message: string }> {
|
||||
return this.makeRequest<{ success: boolean; message: string }>('POST', '/api/frpc/update-config', {
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
// Restart the FRP client on the node
|
||||
async restartFrpc(): Promise<{ success: boolean; message: string }> {
|
||||
return this.makeRequest<{ success: boolean; message: string }>('POST', '/api/frpc/restart');
|
||||
}
|
||||
|
||||
// Check if node is reachable
|
||||
async isReachable(): Promise<boolean> {
|
||||
try {
|
||||
await this.makeRequest<any>('GET', '/health');
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get connection info
|
||||
getConnectionInfo() {
|
||||
return {
|
||||
url: this.config.url,
|
||||
isOnline: this.isOnline,
|
||||
lastConnectionTime: this.lastConnectionTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function to create node client with environment variables
|
||||
export function createNodeClient(): NodeClient {
|
||||
const nodeUrl = process.env.NODE_URL;
|
||||
const nodeToken = process.env.NODE_TOKEN;
|
||||
|
||||
if (!nodeUrl || !nodeToken) {
|
||||
throw new Error('NODE_URL and NODE_TOKEN environment variables are required');
|
||||
}
|
||||
|
||||
return new NodeClient({
|
||||
url: nodeUrl,
|
||||
token: nodeToken,
|
||||
timeout: parseInt(process.env.NODE_TIMEOUT || '5000'),
|
||||
});
|
||||
}
|
||||
376
app/src/server/routes.ts
Normal file
376
app/src/server/routes.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { TunnelDatabase } from './database.js';
|
||||
import { FrpcManager } from './frpc-manager.js';
|
||||
import { createNodeClient } from './node-client.js';
|
||||
import { TunnelConfigSchema, TunnelConfigUpdateSchema, TunnelConfig, TunnelStatus } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Initialize services
|
||||
const db = new TunnelDatabase();
|
||||
const frpcManager = new FrpcManager();
|
||||
|
||||
// Initialize node client if configured
|
||||
let nodeClient: ReturnType<typeof createNodeClient> | null = null;
|
||||
try {
|
||||
nodeClient = createNodeClient();
|
||||
logger.info('Node client initialized successfully');
|
||||
} catch (error) {
|
||||
logger.warn('Node client not configured:', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
|
||||
// Async handler wrapper
|
||||
const asyncHandler = (fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
};
|
||||
|
||||
// Get all tunnels
|
||||
router.get('/tunnels', asyncHandler(async (req: Request, res: Response) => {
|
||||
const tunnels = db.getAllTunnels();
|
||||
res.json(tunnels);
|
||||
}));
|
||||
|
||||
// Get tunnel by ID
|
||||
router.get('/tunnels/:id', asyncHandler(async (req: Request, res: Response) => {
|
||||
const tunnel = db.getTunnelById(req.params.id);
|
||||
if (!tunnel) {
|
||||
return res.status(404).json({ error: 'Tunnel not found' });
|
||||
}
|
||||
res.json(tunnel);
|
||||
}));
|
||||
|
||||
// Create new tunnel
|
||||
router.post('/tunnels', asyncHandler(async (req: Request, res: Response) => {
|
||||
const validation = TunnelConfigSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid tunnel configuration',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const tunnel = db.createTunnel(validation.data);
|
||||
|
||||
// Regenerate frpc config if tunnel is enabled
|
||||
if (tunnel.enabled) {
|
||||
await regenerateFrpcConfig();
|
||||
}
|
||||
|
||||
res.status(201).json(tunnel);
|
||||
}));
|
||||
|
||||
// Update tunnel
|
||||
router.put('/tunnels/:id', asyncHandler(async (req: Request, res: Response) => {
|
||||
const validation = TunnelConfigUpdateSchema.safeParse({
|
||||
...req.body,
|
||||
id: req.params.id
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid tunnel configuration',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const tunnel = db.updateTunnel(req.params.id, validation.data);
|
||||
if (!tunnel) {
|
||||
return res.status(404).json({ error: 'Tunnel not found' });
|
||||
}
|
||||
|
||||
// Regenerate frpc config
|
||||
await regenerateFrpcConfig();
|
||||
|
||||
res.json(tunnel);
|
||||
}));
|
||||
|
||||
// Delete tunnel
|
||||
router.delete('/tunnels/:id', asyncHandler(async (req: Request, res: Response) => {
|
||||
const deleted = db.deleteTunnel(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Tunnel not found' });
|
||||
}
|
||||
|
||||
// Regenerate frpc config
|
||||
await regenerateFrpcConfig();
|
||||
|
||||
res.status(204).send();
|
||||
}));
|
||||
|
||||
// Get tunnel status
|
||||
router.get('/tunnels/:id/status', asyncHandler(async (req: Request, res: Response) => {
|
||||
const tunnel = db.getTunnelById(req.params.id);
|
||||
if (!tunnel) {
|
||||
return res.status(404).json({ error: 'Tunnel not found' });
|
||||
}
|
||||
|
||||
const active = await frpcManager.checkTunnelStatus(tunnel);
|
||||
const status: TunnelStatus = {
|
||||
id: tunnel.id!,
|
||||
name: tunnel.name,
|
||||
active,
|
||||
lastChecked: new Date().toISOString()
|
||||
};
|
||||
|
||||
res.json(status);
|
||||
}));
|
||||
|
||||
// Get all tunnel statuses
|
||||
router.get('/tunnels-status', asyncHandler(async (req: Request, res: Response) => {
|
||||
const tunnels = db.getAllTunnels();
|
||||
const statuses: TunnelStatus[] = [];
|
||||
|
||||
for (const tunnel of tunnels) {
|
||||
try {
|
||||
const active = await frpcManager.checkTunnelStatus(tunnel);
|
||||
statuses.push({
|
||||
id: tunnel.id!,
|
||||
name: tunnel.name,
|
||||
active,
|
||||
lastChecked: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
statuses.push({
|
||||
id: tunnel.id!,
|
||||
name: tunnel.name,
|
||||
active: false,
|
||||
lastChecked: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(statuses);
|
||||
}));
|
||||
|
||||
// Control frpc service
|
||||
router.post('/frpc/:action', asyncHandler(async (req: Request, res: Response) => {
|
||||
const { action } = req.params;
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
await frpcManager.start();
|
||||
break;
|
||||
case 'stop':
|
||||
await frpcManager.stop();
|
||||
break;
|
||||
case 'restart':
|
||||
await frpcManager.restart();
|
||||
break;
|
||||
case 'regenerate':
|
||||
await regenerateFrpcConfig();
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: 'Invalid action' });
|
||||
}
|
||||
|
||||
res.json({ message: `frpc ${action} completed successfully` });
|
||||
}));
|
||||
|
||||
// Get frpc status
|
||||
router.get('/frpc/status', asyncHandler(async (req: Request, res: Response) => {
|
||||
const running = await frpcManager.isRunning();
|
||||
res.json({ running });
|
||||
}));
|
||||
|
||||
// Get frpc logs
|
||||
router.get('/frpc/logs', asyncHandler(async (req: Request, res: Response) => {
|
||||
const lines = parseInt(req.query.lines as string) || 50;
|
||||
const logs = await frpcManager.getLogs(lines);
|
||||
res.json({ logs });
|
||||
}));
|
||||
|
||||
// Helper function to regenerate frpc config and restart if needed
|
||||
async function regenerateFrpcConfig() {
|
||||
const enabledTunnels = db.getEnabledTunnels();
|
||||
|
||||
// Get server configuration from environment variables
|
||||
const serverAddr = process.env.FRPC_SERVER_ADDR || 'your-vps-ip';
|
||||
const serverPort = parseInt(process.env.FRPC_SERVER_PORT || '7000');
|
||||
const token = process.env.FRPC_TOKEN;
|
||||
|
||||
await frpcManager.generateConfig(enabledTunnels, serverAddr, serverPort, token);
|
||||
|
||||
// Restart frpc if it's running
|
||||
if (await frpcManager.isRunning()) {
|
||||
await frpcManager.restart();
|
||||
}
|
||||
}
|
||||
|
||||
// Node management endpoints
|
||||
router.get('/node/status', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!nodeClient) {
|
||||
return res.status(503).json({ error: 'Node client not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await nodeClient.getStatus();
|
||||
const connectionInfo = nodeClient.getConnectionInfo();
|
||||
|
||||
res.json({
|
||||
...status,
|
||||
connection: connectionInfo
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get node status:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to connect to node',
|
||||
connection: nodeClient.getConnectionInfo()
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
router.get('/node/connection', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!nodeClient) {
|
||||
return res.status(503).json({ error: 'Node client not configured' });
|
||||
}
|
||||
|
||||
const connectionInfo = nodeClient.getConnectionInfo();
|
||||
const isReachable = await nodeClient.isReachable();
|
||||
|
||||
res.json({
|
||||
...connectionInfo,
|
||||
isReachable
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/node/push-config', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!nodeClient) {
|
||||
return res.status(503).json({ error: 'Node client not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Regenerate frpc config
|
||||
await regenerateFrpcConfig();
|
||||
|
||||
// Read the generated config
|
||||
const enabledTunnels = db.getEnabledTunnels();
|
||||
const serverAddr = process.env.FRPC_SERVER_ADDR || 'your-vps-ip';
|
||||
const serverPort = parseInt(process.env.FRPC_SERVER_PORT || '7000');
|
||||
const token = process.env.FRPC_TOKEN;
|
||||
|
||||
// Generate config content
|
||||
let tomlContent = `[common]
|
||||
server_addr = "${serverAddr}"
|
||||
server_port = ${serverPort}
|
||||
`;
|
||||
|
||||
if (token) {
|
||||
tomlContent += `token = "${token}"\n`;
|
||||
}
|
||||
|
||||
tomlContent += '\n';
|
||||
|
||||
// Add proxy configurations
|
||||
for (const tunnel of enabledTunnels) {
|
||||
tomlContent += `[${tunnel.name}]
|
||||
type = "${tunnel.protocol.toLowerCase()}"
|
||||
local_ip = "${tunnel.localIp}"
|
||||
local_port = ${tunnel.localPort}
|
||||
remote_port = ${tunnel.remotePort}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// Send config to node
|
||||
const result = await nodeClient.updateConfig(tomlContent);
|
||||
|
||||
logger.info('Configuration pushed to node successfully');
|
||||
res.json({
|
||||
message: 'Configuration pushed to node successfully',
|
||||
tunnelCount: enabledTunnels.length,
|
||||
nodeResponse: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to push config to node:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to push configuration to node',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
router.post('/node/restart-frpc', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!nodeClient) {
|
||||
return res.status(503).json({ error: 'Node client not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await nodeClient.restartFrpc();
|
||||
|
||||
logger.info('frpc restarted on node successfully');
|
||||
res.json({
|
||||
message: 'frpc restarted on node successfully',
|
||||
nodeResponse: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to restart frpc on node:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to restart frpc on node',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
router.post('/node/push-and-restart', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!nodeClient) {
|
||||
return res.status(503).json({ error: 'Node client not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
// First push the config
|
||||
await regenerateFrpcConfig();
|
||||
|
||||
// Read the generated config
|
||||
const enabledTunnels = db.getEnabledTunnels();
|
||||
const serverAddr = process.env.FRPC_SERVER_ADDR || 'your-vps-ip';
|
||||
const serverPort = parseInt(process.env.FRPC_SERVER_PORT || '7000');
|
||||
const token = process.env.FRPC_TOKEN;
|
||||
|
||||
// Generate config content
|
||||
let tomlContent = `[common]
|
||||
server_addr = "${serverAddr}"
|
||||
server_port = ${serverPort}
|
||||
`;
|
||||
|
||||
if (token) {
|
||||
tomlContent += `token = "${token}"\n`;
|
||||
}
|
||||
|
||||
tomlContent += '\n';
|
||||
|
||||
// Add proxy configurations
|
||||
for (const tunnel of enabledTunnels) {
|
||||
tomlContent += `[${tunnel.name}]
|
||||
type = "${tunnel.protocol.toLowerCase()}"
|
||||
local_ip = "${tunnel.localIp}"
|
||||
local_port = ${tunnel.localPort}
|
||||
remote_port = ${tunnel.remotePort}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// Send config to node and restart in one call
|
||||
const result = await nodeClient.updateConfig(tomlContent);
|
||||
const restartResult = await nodeClient.restartFrpc();
|
||||
|
||||
logger.info('Configuration pushed and frpc restarted on node successfully');
|
||||
res.json({
|
||||
message: 'Configuration pushed and frpc restarted on node successfully',
|
||||
tunnelCount: enabledTunnels.length,
|
||||
configResponse: result,
|
||||
restartResponse: restartResult
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to push config and restart frpc on node:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to push configuration and restart frpc on node',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
export default router;
|
||||
43
app/src/server/types.ts
Normal file
43
app/src/server/types.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Zod schemas for validation
|
||||
export const TunnelConfigSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
protocol: z.enum(['TCP', 'UDP']),
|
||||
localIp: z.string().min(1, 'Local IP is required'),
|
||||
localPort: z.number().int().min(1).max(65535),
|
||||
remotePort: z.number().int().min(1).max(65535),
|
||||
enabled: z.boolean().default(true),
|
||||
createdAt: z.string().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TunnelConfigUpdateSchema = TunnelConfigSchema.partial().extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type TunnelConfig = z.infer<typeof TunnelConfigSchema>;
|
||||
export type TunnelConfigUpdate = z.infer<typeof TunnelConfigUpdateSchema>;
|
||||
|
||||
export interface TunnelStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
lastChecked: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FrpcConfig {
|
||||
serverAddr: string;
|
||||
serverPort: number;
|
||||
token?: string;
|
||||
proxies: {
|
||||
[key: string]: {
|
||||
type: 'tcp' | 'udp';
|
||||
localIP: string;
|
||||
localPort: number;
|
||||
remotePort: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue