Initial commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
89
App.tsx
Normal 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
59
README.md
Normal 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.
|
||||||
130
components/AddServiceModal.tsx
Normal file
130
components/AddServiceModal.tsx
Normal 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
44
components/Header.tsx
Normal 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;
|
||||||
166
components/ScanInfoModal.tsx
Normal file
166
components/ScanInfoModal.tsx
Normal 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;
|
||||||
50
components/ServiceCard.tsx
Normal file
50
components/ServiceCard.tsx
Normal 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
48
constants.ts
Normal 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
49
hooks/useLocalStorage.ts
Normal 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
43
index.html
Normal 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
16
index.tsx
Normal 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
5
metadata.json
Normal 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
22
package.json
Normal 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
21
server/package.json
Normal 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
92
server/server.js
Normal 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
42
services/geminiService.ts
Normal 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}.`;
|
||||||
|
}
|
||||||
|
};
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal 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
10
types.ts
Normal 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
17
vite.config.ts
Normal 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, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user