Initial commit

This commit is contained in:
2025-09-14 13:09:39 -07:00
commit 9dcc0868de
20 changed files with 956 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

89
App.tsx Normal file
View File

@@ -0,0 +1,89 @@
import React, { useState, useCallback } from 'react';
import { type Service } from './types';
import { useLocalStorage } from './hooks/useLocalStorage';
import { PRECONFIGURED_SERVICES } from './constants';
import ServiceCard from './components/ServiceCard';
import AddServiceModal from './components/AddServiceModal';
import Header from './components/Header';
import ScanInfoModal from './components/ScanInfoModal';
import { Plus } from 'lucide-react';
import { Globe } from 'lucide-react';
const App: React.FC = () => {
const [services, setServices] = useLocalStorage<Service[]>('dashboard-services', PRECONFIGURED_SERVICES);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isScanInfoModalOpen, setIsScanInfoModalOpen] = useState(false);
const addService = useCallback((newService: Omit<Service, 'id'>) => {
setServices(prevServices => [
...prevServices,
{ ...newService, id: Date.now().toString() }
]);
}, [setServices]);
const addDiscoveredServices = useCallback((discoveredServices: Omit<Service, 'id'>[]) => {
setServices(prevServices => {
const existingUrls = new Set(prevServices.map(s => s.url));
const newServices = discoveredServices
.filter(ds => !existingUrls.has(ds.url))
.map(ds => ({
...ds,
id: `${ds.name}-${ds.url}-${Math.random()}`, // Create a unique ID
icon: Globe, // Default icon for discovered services
}));
return [...prevServices, ...newServices];
});
}, [setServices]);
const deleteService = useCallback((id: string) => {
setServices(prevServices => prevServices.filter(service => service.id !== id));
}, [setServices]);
return (
<div className="min-h-screen bg-gray-900 text-gray-100 font-sans">
<Header
onAddService={() => setIsModalOpen(true)}
onScanNetwork={() => setIsScanInfoModalOpen(true)}
/>
<main className="container mx-auto p-4 md:p-8">
{services.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{services.map(service => (
<ServiceCard key={service.id} service={service} onDelete={deleteService} />
))}
</div>
) : (
<div className="text-center py-20">
<h2 className="text-2xl font-semibold text-gray-400">Your Dashboard is Empty</h2>
<p className="text-gray-500 mt-2">Click the button below to start adding your local services.</p>
<button
onClick={() => setIsModalOpen(true)}
className="mt-6 inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-teal-500 hover:bg-teal-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-teal-500 transition-colors"
>
<Plus className="-ml-1 mr-3 h-5 w-5" />
Add Your First Service
</button>
</div>
)}
</main>
{isModalOpen && (
<AddServiceModal
onClose={() => setIsModalOpen(false)}
onAddService={addService}
/>
)}
{isScanInfoModalOpen && (
<ScanInfoModal
onClose={() => setIsScanInfoModalOpen(false)}
onScanComplete={addDiscoveredServices}
/>
)}
</div>
);
};
export default App;

59
README.md Normal file
View File

@@ -0,0 +1,59 @@
# Local Network Dashboard
A beautiful, self-hosted dashboard to organize and access all your local network services.
This project consists of two parts:
1. **Frontend:** A React-based dashboard that runs in your browser.
2. **Backend:** A Node.js server that you run locally to perform network scans.
---
## Running the Application
### 1. Prerequisites
Before you start, you need to have **Nmap** installed on the computer where you will run the backend server. Nmap is a powerful network scanning tool that the backend uses to discover services.
- **macOS (using Homebrew):**
```bash
brew install nmap
```
- **Debian/Ubuntu:**
```bash
sudo apt-get update && sudo apt-get install nmap
```
- **Windows:**
Download the installer from the [official Nmap website](https://nmap.org/download.html).
You also need [Node.js](https://nodejs.org/) installed to run the backend server.
### 2. Running the Backend Server
The backend server is responsible for scanning your network and finding services.
1. **Navigate to the `server` directory:**
```bash
cd server
```
2. **Install dependencies:**
```bash
npm install
```
3. **Start the server:**
```bash
node server.js
```
The server will start and listen on port 3001. You should see a message `Backend server running on http://localhost:3001`. Keep this terminal window open.
### 3. Running the Frontend
The frontend is the visual dashboard you interact with. Simply open the `index.html` file in your web browser.
- Once the dashboard is open, click the "Scan Network" button.
- Enter your network range (e.g., `192.168.1.0/24`).
- Click "Start Scan".
The frontend will contact your running backend server, which will perform the nmap scan and return the discovered services to be automatically added to your dashboard.

View File

@@ -0,0 +1,130 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { type Service } from '../types';
import { generateServiceDescription } from '../services/geminiService';
import { Globe, Loader2 } from 'lucide-react';
interface AddServiceModalProps {
onClose: () => void;
onAddService: (service: Omit<Service, 'id'>) => void;
}
const AddServiceModal: React.FC<AddServiceModalProps> = ({ onClose, onAddService }) => {
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [description, setDescription] = useState('');
const [isFetchingDesc, setIsFetchingDesc] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
const handleFetchDescription = useCallback(async () => {
if (name.trim().length > 2 && process.env.API_KEY) {
setIsFetchingDesc(true);
const desc = await generateServiceDescription(name);
setDescription(desc);
setIsFetchingDesc(false);
}
}, [name]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (name && url) {
onAddService({
name,
url,
description: description || `A local service at ${url}`,
icon: Globe,
});
onClose();
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-70 z-50 flex justify-center items-center p-4">
<div ref={modalRef} className="bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6 border border-gray-700">
<h2 className="text-2xl font-bold mb-6 text-white">Add a New Service</h2>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300">
Service Name
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={handleFetchDescription}
className="mt-1 block w-full bg-gray-900 border border-gray-600 rounded-md shadow-sm py-2 px-3 text-white focus:outline-none focus:ring-teal-500 focus:border-teal-500 sm:text-sm"
placeholder="e.g., Home Assistant"
required
/>
</div>
<div>
<label htmlFor="url" className="block text-sm font-medium text-gray-300">
URL (including http/https)
</label>
<input
type="text"
id="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="mt-1 block w-full bg-gray-900 border border-gray-600 rounded-md shadow-sm py-2 px-3 text-white focus:outline-none focus:ring-teal-500 focus:border-teal-500 sm:text-sm"
placeholder="http://192.168.1.100:8123"
required
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300">
Description
</label>
<div className="relative">
<textarea
id="description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 block w-full bg-gray-900 border border-gray-600 rounded-md shadow-sm py-2 px-3 text-white focus:outline-none focus:ring-teal-500 focus:border-teal-500 sm:text-sm"
placeholder="A short description of the service..."
/>
{isFetchingDesc && (
<div className="absolute inset-y-0 right-3 flex items-center">
<Loader2 className="h-5 w-5 text-teal-400 animate-spin" />
</div>
)}
</div>
</div>
</div>
<div className="mt-8 flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-700 rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-gray-500"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-teal-500 rounded-md shadow-sm hover:bg-teal-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-teal-500"
>
Add Service
</button>
</div>
</form>
</div>
</div>
);
};
export default AddServiceModal;

44
components/Header.tsx Normal file
View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Plus, Network, Scan } from 'lucide-react';
interface HeaderProps {
onAddService: () => void;
onScanNetwork: () => void;
}
const Header: React.FC<HeaderProps> = ({ onAddService, onScanNetwork }) => {
return (
<header className="bg-gray-800/50 backdrop-blur-sm sticky top-0 z-10 shadow-md">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-20">
<div className="flex items-center">
<Network className="h-8 w-8 text-teal-400" />
<h1 className="ml-3 text-2xl font-bold text-gray-100 tracking-tight">
Local Dashboard
</h1>
</div>
<div className="flex items-center space-x-3">
<button
onClick={onScanNetwork}
type="button"
className="inline-flex items-center px-4 py-2 border border-gray-600 text-sm font-medium rounded-md shadow-sm text-gray-200 bg-gray-700 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-teal-500 transition-colors"
>
<Scan className="-ml-1 mr-2 h-5 w-5" />
Scan Network
</button>
<button
onClick={onAddService}
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-teal-500 hover:bg-teal-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-teal-500 transition-colors"
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Service
</button>
</div>
</div>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,166 @@
import React, { useRef, useEffect, useState } from 'react';
import { AlertTriangle, Loader2, ScanSearch, ServerCrash } from 'lucide-react';
import { type Service } from '../types';
interface ScanInfoModalProps {
onClose: () => void;
onScanComplete: (services: Omit<Service, 'id'>[]) => void;
}
type ScanStatus = 'idle' | 'scanning' | 'error';
const ScanInfoModal: React.FC<ScanInfoModalProps> = ({ onClose, onScanComplete }) => {
const modalRef = useRef<HTMLDivElement>(null);
const [networkAddress, setNetworkAddress] = useState('192.168.1.0/24');
const [scanStatus, setScanStatus] = useState<ScanStatus>('idle');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
const handleStartScan = async (e: React.FormEvent) => {
e.preventDefault();
setScanStatus('scanning');
setErrorMessage('');
try {
const response = await fetch('http://localhost:3001/api/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ network: networkAddress }),
});
if (!response.ok) {
// Try to parse a specific error message from the backend, otherwise show a generic one.
const errorData = await response.json().catch(() => ({ error: `Request failed with status: ${response.status}` }));
setErrorMessage(errorData.error || 'An unknown error occurred during the scan.');
setScanStatus('error');
return;
}
const discoveredServices = await response.json();
onScanComplete(discoveredServices);
onClose();
} catch (err) {
// This catch block handles network errors (e.g., server not running)
console.error(err);
setErrorMessage('Scan failed. Is the backend server running? Make sure you have started the server in the `server` directory and that nmap is installed. See README.md for details.');
setScanStatus('error');
}
};
const renderContent = () => {
switch (scanStatus) {
case 'scanning':
return (
<div className="text-center py-8">
<Loader2 className="h-12 w-12 text-teal-400 animate-spin mx-auto" />
<h3 className="mt-4 text-xl font-semibold text-white">Scanning Network</h3>
<p className="text-gray-400 mt-1">
Discovering services on <span className="font-mono text-teal-300">{networkAddress}</span>...
</p>
<p className="text-gray-500 text-sm mt-2">This may take a few minutes depending on your network.</p>
</div>
);
case 'error':
return (
<div>
<div className="flex items-center mb-4">
<div className="p-2 bg-red-500/20 rounded-full mr-4">
<ServerCrash className="h-6 w-6 text-red-400" />
</div>
<h2 className="text-2xl font-bold text-white">Scan Failed</h2>
</div>
<div className="bg-red-900/50 border border-red-700 text-red-200 px-4 py-3 rounded-lg relative" role="alert">
<strong className="font-bold flex items-center"><AlertTriangle className="mr-2"/>Error Details</strong>
<span className="block sm:inline mt-2">{errorMessage}</span>
</div>
<div className="mt-8 flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-700 rounded-md hover:bg-gray-600"
>
Close
</button>
<button
type="button"
onClick={() => setScanStatus('idle')}
className="px-6 py-2 text-sm font-medium text-white bg-teal-500 rounded-md shadow-sm hover:bg-teal-600"
>
Try Again
</button>
</div>
</div>
);
case 'idle':
default:
return (
<form onSubmit={handleStartScan}>
<div className="flex items-center mb-4">
<div className="p-2 bg-teal-500/20 rounded-full mr-4">
<ScanSearch className="h-6 w-6 text-teal-400" />
</div>
<h2 className="text-2xl font-bold text-white">Scan Your Local Network</h2>
</div>
<div className="text-gray-300 space-y-4">
<p>
Enter your local network address range below to automatically discover web services.
</p>
<div>
<label htmlFor="network-address" className="block text-sm font-medium text-gray-300 mb-1">
Network IP Address (CIDR format)
</label>
<input
type="text"
id="network-address"
value={networkAddress}
onChange={(e) => setNetworkAddress(e.target.value)}
className="mt-1 block w-full bg-gray-900 border border-gray-600 rounded-md shadow-sm py-2 px-3 text-white focus:outline-none focus:ring-teal-500 focus:border-teal-500 sm:text-sm font-mono"
placeholder="e.g., 192.168.1.0/24"
required
/>
</div>
</div>
<div className="mt-8 flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-700 rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-gray-500"
>
Cancel
</button>
<button
type="submit"
className="inline-flex items-center px-6 py-2 text-sm font-medium text-white bg-teal-500 rounded-md shadow-sm hover:bg-teal-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-teal-500"
>
Start Scan
</button>
</div>
</form>
);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-70 z-50 flex justify-center items-center p-4">
<div ref={modalRef} className="bg-gray-800 rounded-lg shadow-xl w-full max-w-lg p-6 border border-gray-700 transition-all duration-300">
{renderContent()}
</div>
</div>
);
};
export default ScanInfoModal;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { type Service } from '../types';
import { Globe, X } from 'lucide-react';
interface ServiceCardProps {
service: Service;
onDelete: (id: string) => void;
}
const ServiceCard: React.FC<ServiceCardProps> = ({ service, onDelete }) => {
const IconComponent = typeof service.icon === 'string' ? Globe : service.icon;
const handleDelete = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (window.confirm(`Are you sure you want to delete ${service.name}?`)) {
onDelete(service.id);
}
};
return (
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="group relative block p-6 bg-gray-800 rounded-lg shadow-lg hover:shadow-2xl hover:bg-gray-700 transition-all duration-300 transform hover:-translate-y-1"
>
<button
onClick={handleDelete}
className="absolute top-2 right-2 p-1.5 bg-gray-700/50 text-gray-400 rounded-full hover:bg-red-500/50 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity focus:opacity-100 z-10"
aria-label={`Delete ${service.name}`}
>
<X className="h-4 w-4" />
</button>
<div className="flex items-center mb-4">
<div className="p-3 bg-gray-900 rounded-lg mr-4">
<IconComponent className="h-6 w-6 text-teal-400" />
</div>
<h3 className="text-lg font-semibold text-gray-100 truncate">{service.name}</h3>
</div>
<p className="text-sm text-gray-400 h-10 overflow-hidden">
{service.description}
</p>
</a>
);
};
export default ServiceCard;

48
constants.ts Normal file
View File

@@ -0,0 +1,48 @@
import { type Service } from './types';
import { Home, Server, Router, Shield, Film, Gamepad2, Database } from 'lucide-react';
export const PRECONFIGURED_SERVICES: Service[] = [
{
id: '1',
name: 'Home Assistant',
url: 'http://homeassistant.local:8123',
description: 'Open source home automation that puts local control and privacy first.',
icon: Home,
},
{
id: '2',
name: 'Plex Media Server',
url: 'http://<your-plex-ip>:32400/web',
description: 'Stream your media collection to any device, anywhere.',
icon: Film,
},
{
id: '3',
name: 'Pi-hole',
url: 'http://<your-pi-hole-ip>/admin',
description: 'A black hole for Internet advertisements.',
icon: Shield,
},
{
id: '4',
name: 'Router Admin',
url: 'http://192.168.1.1',
description: 'Access your network router\'s configuration page.',
icon: Router,
},
{
id: '5',
name: 'Proxmox VE',
url: 'https://<your-proxmox-ip>:8006',
description: 'Open-source server management platform for virtualization.',
icon: Server,
},
{
id: '6',
name: 'Portainer',
url: 'http://<your-docker-ip>:9000',
description: 'Manage your Docker environments with a simple web UI.',
icon: Database,
},
];

49
hooks/useLocalStorage.ts Normal file
View File

@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
type SetValue<T> = (value: T | ((val: T) => T)) => void;
export const useLocalStorage = <T,>(key: string, initialValue: T): [T, SetValue<T>] => {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue: SetValue<T> = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error(error);
}
};
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key) {
try {
setStoredValue(e.newValue ? JSON.parse(e.newValue) : initialValue);
} catch (error) {
console.error(error)
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [initialValue, key]);
return [storedValue, setValue];
};

43
index.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Local Network Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'gray-900': '#1a202c',
'gray-800': '#2d3748',
'gray-700': '#4a5568',
'gray-600': '#718096',
'teal-500': '#38b2ac',
'teal-400': '#4fd1c5',
}
}
}
}
</script>
<script type="importmap">
{
"imports": {
"react/": "https://aistudiocdn.com/react@^19.1.1/",
"react": "https://aistudiocdn.com/react@^19.1.1",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.1.1/",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.19.0",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.544.0"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-gray-900 text-white">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

16
index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "Copy of Local Network Dashboard",
"description": "A beautiful, self-hosted dashboard to organize and access all your local network services. Manually add your services and get AI-powered descriptions to create a personalized homepage.",
"requestFramePermissions": []
}

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "copy-of-local-network-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1",
"@google/genai": "^1.19.0",
"lucide-react": "^0.544.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

21
server/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "local-dashboard-backend",
"version": "1.0.0",
"description": "Backend service for the Local Network Dashboard to perform nmap scans.",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"keywords": [
"nmap",
"network-scan",
"dashboard"
],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"xml2js": "^0.6.2"
}
}

92
server/server.js Normal file
View File

@@ -0,0 +1,92 @@
const express = require('express');
const { exec } = require('child_process');
const xml2js = require('xml2js');
const cors = require('cors');
const os = require('os');
const { promisify } = require('util');
const execAsync = promisify(exec);
const app = express();
const port = 3001;
// Use CORS to allow requests from the frontend
app.use(cors());
app.use(express.json());
// Basic validation for CIDR notation
const isValidCIDR = (cidr) => {
const cidrRegex = /^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))$/;
return cidrRegex.test(cidr);
};
app.post('/api/scan', async (req, res) => {
const { network } = req.body;
if (!network || !isValidCIDR(network)) {
return res.status(400).json({ error: 'Invalid or missing network address in CIDR format (e.g., 192.168.1.0/24).' });
}
// Sanitize input to prevent command injection
// The regex validation is the primary defense here.
const sanitizedNetwork = network;
console.log(`[SCAN STARTED] for network: ${sanitizedNetwork}`);
// Command to find hosts with a wide range of common web ports open and output as XML.
// This is more efficient than scanning all 65535 ports.
const command = `nmap -p 80,443,3000,5000,7878,8080,8443,9000,9090,9091,9443,10000,32400,8000-8999 --open ${sanitizedNetwork} -oX -`;
try {
const { stdout, stderr } = await execAsync(command, { timeout: 300000 }); // 5 minute timeout
if (stderr) {
console.error(`[NMAP STDERR] ${stderr}`);
}
const parser = new xml2js.Parser();
const result = await parser.parseStringPromise(stdout);
const services = [];
if (result.nmaprun && result.nmaprun.host) {
result.nmaprun.host.forEach(host => {
const address = host.address[0].$.addr;
if (host.ports && host.ports[0].port) {
host.ports[0].port.forEach(portInfo => {
const port = portInfo.$.portid;
let url;
if (port === '443') {
url = `https://${address}`;
} else if (port === '80') {
url = `http://${address}`;
} else {
url = `http://${address}:${port}`;
}
const serviceName = portInfo.service && portInfo.service[0].$.name
? portInfo.service[0].$.name.replace(/-http$/, '') // Clean up names like 'http-proxy'
: `Web Service @ ${address}`;
services.push({
name: `${serviceName.charAt(0).toUpperCase() + serviceName.slice(1)} (${port})`,
url: url,
description: `Discovered service on port ${port}.`
});
});
}
});
}
console.log(`[SCAN COMPLETE] Found ${services.length} services on ${sanitizedNetwork}.`);
res.json(services);
} catch (error) {
console.error(`[SCAN FAILED] Error executing nmap:`, error);
res.status(500).json({ error: 'Failed to execute nmap scan. Is nmap installed and in your system PATH?' });
}
});
app.listen(port, () => {
console.log(`Backend server running on http://localhost:${port}`);
});

42
services/geminiService.ts Normal file
View File

@@ -0,0 +1,42 @@
import { GoogleGenAI, Type } from "@google/genai";
const API_KEY = process.env.API_KEY;
if (!API_KEY) {
console.warn("Gemini API key not found. AI features will be disabled. Please set process.env.API_KEY.");
}
const ai = new GoogleGenAI({ apiKey: API_KEY! });
export const generateServiceDescription = async (serviceName: string): Promise<string> => {
if (!API_KEY) {
return `A local instance of ${serviceName}.`;
}
try {
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: `Provide a concise, one-sentence description for a self-hosted service named "${serviceName}". This description will be shown on a personal dashboard.`,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
description: {
type: Type.STRING,
description: "The generated one-sentence description for the service.",
},
},
},
},
});
const jsonText = response.text.trim();
const result = JSON.parse(jsonText);
return result.description || `A local instance of ${serviceName}.`;
} catch (error) {
console.error("Error fetching description from Gemini:", error);
return `A local instance of ${serviceName}.`;
}
};

0
setup.sh Normal file
View File

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

10
types.ts Normal file
View File

@@ -0,0 +1,10 @@
import { type LucideIcon } from 'lucide-react';
export interface Service {
id: string;
name: string;
url: string;
description: string;
icon: LucideIcon | string;
}

17
vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});