├── .gitignore ├── images ├── DarkMode.png ├── LightMode.png ├── details off.png ├── details on.png ├── DarkModewbackground.png ├── ServiceModewBackground.png ├── TransparentDarkwBackground.png ├── TransparentLightWbackground.png ├── DashboardConfiguration-Groups-1.png ├── DashboardConfiguration-Groups-2.png ├── DashboardConfiguration-Icons-1.png ├── DashboardConfiguration-Icons-2.png ├── DashboardConfiguration-Services-1.png ├── DashboardConfiguration-Services-2.png └── DashboardConfiguration-GeneralSettings.png ├── migrations ├── 002_optional_ip_port.sql ├── run_002_migration.sh ├── 003_add_pages.sql ├── 004_add_widgets.sql ├── 005_add_alerts.sql └── 001_initial_schema.sql ├── src ├── index.js ├── components │ ├── RootRedirect.js │ ├── ServiceCard.js │ ├── NavigationMenu.js │ ├── widgets │ │ ├── WidgetContainer.js │ │ ├── WeatherWidget.js │ │ ├── DateTimeWidget.js │ │ └── SunPositionWidget.js │ ├── ConfigEditor.js │ └── config │ │ └── ConfigurationPage.js ├── themes │ ├── backgrounds.css │ └── themeConfig.js └── App.js ├── public ├── index.html └── themes │ └── backgrounds.css ├── server ├── package.json ├── migrations │ ├── 005_add_alerts.sql │ └── 001_initial_schema.sql └── index.js ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue-report.md ├── docker-compose.yml ├── package.json ├── nginx.conf ├── test-alerts.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | README.md 2 | CHANGELOG.md 3 | WIDGETS.md -------------------------------------------------------------------------------- /images/DarkMode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/DarkMode.png -------------------------------------------------------------------------------- /images/LightMode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/LightMode.png -------------------------------------------------------------------------------- /images/details off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/details off.png -------------------------------------------------------------------------------- /images/details on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/details on.png -------------------------------------------------------------------------------- /images/DarkModewbackground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/DarkModewbackground.png -------------------------------------------------------------------------------- /images/ServiceModewBackground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/ServiceModewBackground.png -------------------------------------------------------------------------------- /images/TransparentDarkwBackground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/TransparentDarkwBackground.png -------------------------------------------------------------------------------- /images/TransparentLightWbackground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/TransparentLightWbackground.png -------------------------------------------------------------------------------- /images/DashboardConfiguration-Groups-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/DashboardConfiguration-Groups-1.png -------------------------------------------------------------------------------- /images/DashboardConfiguration-Groups-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/DashboardConfiguration-Groups-2.png -------------------------------------------------------------------------------- /images/DashboardConfiguration-Icons-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/DashboardConfiguration-Icons-1.png -------------------------------------------------------------------------------- /images/DashboardConfiguration-Icons-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/DashboardConfiguration-Icons-2.png -------------------------------------------------------------------------------- /images/DashboardConfiguration-Services-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/DashboardConfiguration-Services-1.png -------------------------------------------------------------------------------- /images/DashboardConfiguration-Services-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/DashboardConfiguration-Services-2.png -------------------------------------------------------------------------------- /images/DashboardConfiguration-GeneralSettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/HEAD/images/DashboardConfiguration-GeneralSettings.png -------------------------------------------------------------------------------- /migrations/002_optional_ip_port.sql: -------------------------------------------------------------------------------- 1 | -- Make ip and port optional in services table 2 | ALTER TABLE services ALTER COLUMN ip DROP NOT NULL; 3 | ALTER TABLE services ALTER COLUMN port DROP NOT NULL; 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | 5 | const root = ReactDOM.createRoot(document.getElementById('root')); 6 | root.render(); 7 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Homelab Dashboard 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /migrations/run_002_migration.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Run the migration 5 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL 6 | -- Make ip and port optional in services table 7 | ALTER TABLE services ALTER COLUMN ip DROP NOT NULL; 8 | ALTER TABLE services ALTER COLUMN port DROP NOT NULL; 9 | EOSQL 10 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ditdashdot-server", 3 | "version": "1.0.0", 4 | "description": "Backend server for DitDashDot configuration", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js", 8 | "dev": "nodemon index.js" 9 | }, 10 | "dependencies": { 11 | "express": "^4.18.2", 12 | "pg": "^8.11.3", 13 | "cors": "^2.8.5", 14 | "dotenv": "^16.3.1", 15 | "express-async-handler": "^1.2.0" 16 | }, 17 | "devDependencies": { 18 | "nodemon": "^3.0.1" 19 | } 20 | } -------------------------------------------------------------------------------- /migrations/003_add_pages.sql: -------------------------------------------------------------------------------- 1 | -- Create pages table 2 | CREATE TABLE IF NOT EXISTS pages ( 3 | id SERIAL PRIMARY KEY, 4 | title VARCHAR(255) NOT NULL, 5 | display_order INTEGER NOT NULL DEFAULT 0, 6 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 7 | ); 8 | 9 | -- Add page_id to groups table 10 | ALTER TABLE groups ADD COLUMN IF NOT EXISTS page_id INTEGER REFERENCES pages(id) ON DELETE CASCADE; 11 | 12 | -- Create default page if none exist 13 | INSERT INTO pages (title, display_order) 14 | SELECT 'Home', 0 15 | WHERE NOT EXISTS (SELECT 1 FROM pages); 16 | 17 | -- Update existing groups to use the default page 18 | UPDATE groups SET page_id = (SELECT id FROM pages ORDER BY display_order LIMIT 1) WHERE page_id IS NULL; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: Pending 6 | assignees: SluberskiHomeLab 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /migrations/004_add_widgets.sql: -------------------------------------------------------------------------------- 1 | -- Create widgets table 2 | CREATE TABLE IF NOT EXISTS widgets ( 3 | id SERIAL PRIMARY KEY, 4 | type VARCHAR(50) NOT NULL, 5 | title VARCHAR(255), 6 | config JSONB DEFAULT '{}', 7 | page_id INTEGER REFERENCES pages(id) ON DELETE CASCADE, 8 | display_order INTEGER NOT NULL DEFAULT 0, 9 | enabled BOOLEAN DEFAULT true, 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 11 | ); 12 | 13 | -- Insert some default widgets 14 | INSERT INTO widgets (type, title, config, page_id, display_order, enabled) 15 | SELECT 16 | 'datetime', 17 | 'Current Time', 18 | '{"showSeconds": true, "format12Hour": false, "showGreeting": true, "showTimezone": true}', 19 | (SELECT id FROM pages ORDER BY display_order LIMIT 1), 20 | 0, 21 | true 22 | WHERE NOT EXISTS (SELECT 1 FROM widgets WHERE type = 'datetime'); -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue Report 3 | about: Submit an Issue or Bug with the application 4 | title: '' 5 | labels: Pending 6 | assignees: SluberskiHomeLab 7 | 8 | --- 9 | 10 | **Describe the issue** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Server (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Client (please complete the following information):** 32 | - OS: [e.g. iOS] 33 | - Browser [e.g. chrome, safari] 34 | - Version [e.g. 22] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dashboard: 3 | image: sluberskihomelab/ditdashdot-dashboard:latest 4 | ports: 5 | - "80:80" 6 | volumes: 7 | - ./background.jpg:/usr/share/nginx/html/background.jpg 8 | environment: 9 | - API_URL=http://api:3001 10 | depends_on: 11 | - api 12 | - db 13 | restart: always 14 | 15 | api: 16 | image: sluberskihomelab/ditdashdot-api:latest 17 | ports: 18 | - "3001:3001" 19 | environment: 20 | - POSTGRES_USER=ditdashdot 21 | - POSTGRES_PASSWORD=ditdashdot 22 | - POSTGRES_DB=ditdashdot 23 | - POSTGRES_HOST=db 24 | depends_on: 25 | - db 26 | restart: always 27 | 28 | db: 29 | image: postgres:14-alpine 30 | ports: 31 | - "5432:5432" 32 | environment: 33 | - POSTGRES_USER=ditdashdot 34 | - POSTGRES_PASSWORD=ditdashdot 35 | - POSTGRES_DB=ditdashdot 36 | volumes: 37 | - postgres_data:/var/lib/postgresql/data 38 | - ./migrations:/docker-entrypoint-initdb.d 39 | restart: always 40 | 41 | volumes: 42 | postgres_data: 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homelab-dashboard", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "js-yaml": "^4.1.0", 7 | "react": "^18.2.0", 8 | "react-dom": "^18.2.0", 9 | "react-scripts": "5.0.1", 10 | "react-router-dom": "^6.16.0", 11 | "@mui/material": "^5.14.10", 12 | "@mui/icons-material": "^5.14.10", 13 | "@emotion/react": "^11.11.1", 14 | "@emotion/styled": "^11.11.0", 15 | "axios": "^1.5.0", 16 | "react-beautiful-dnd": "^13.1.1" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": [ 26 | "react-app" 27 | ], 28 | "rules": { 29 | "react-hooks/exhaustive-deps": "warn", 30 | "no-unused-vars": "warn" 31 | } 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/RootRedirect.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Navigate } from 'react-router-dom'; 3 | import { CircularProgress, Box } from '@mui/material'; 4 | 5 | const RootRedirect = () => { 6 | const [firstPageId, setFirstPageId] = useState(null); 7 | const [loading, setLoading] = useState(true); 8 | 9 | useEffect(() => { 10 | const fetchFirstPage = async () => { 11 | try { 12 | const response = await fetch('/api/pages'); 13 | const pages = await response.json(); 14 | if (pages.length > 0) { 15 | const sortedPages = pages.sort((a, b) => a.display_order - b.display_order); 16 | setFirstPageId(sortedPages[0].id); 17 | } 18 | } catch (error) { 19 | console.error('Error fetching pages:', error); 20 | } finally { 21 | setLoading(false); 22 | } 23 | }; 24 | 25 | fetchFirstPage(); 26 | }, []); 27 | 28 | if (loading) { 29 | return ( 30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | if (firstPageId) { 37 | return ; 38 | } 39 | 40 | // If no pages exist, show a message or redirect to config 41 | return ; 42 | }; 43 | 44 | export default RootRedirect; -------------------------------------------------------------------------------- /public/themes/backgrounds.css: -------------------------------------------------------------------------------- 1 | /* CSS-based background patterns for themes */ 2 | 3 | .retro-bg { 4 | background: 5 | radial-gradient(circle at 25% 25%, #8b4513 0%, transparent 50%), 6 | radial-gradient(circle at 75% 75%, #d2b48c 0%, transparent 50%), 7 | linear-gradient(45deg, #f5f5dc 25%, transparent 25%), 8 | linear-gradient(-45deg, #ddd8c0 25%, transparent 25%), 9 | linear-gradient(45deg, transparent 75%, #f5f5dc 75%), 10 | linear-gradient(-45deg, transparent 75%, #ddd8c0 75%); 11 | background-size: 60px 60px, 60px 60px, 30px 30px, 30px 30px, 30px 30px, 30px 30px; 12 | background-position: 0 0, 30px 30px, 0 0, 15px 0, 15px 15px, 0 15px; 13 | } 14 | 15 | .matrix-bg { 16 | background: 17 | linear-gradient(90deg, transparent 98%, #00ff00 100%), 18 | linear-gradient(0deg, transparent 98%, #00ff00 100%); 19 | background-size: 20px 20px, 20px 20px; 20 | animation: matrix-scroll 20s linear infinite; 21 | } 22 | 23 | @keyframes matrix-scroll { 24 | 0% { background-position: 0 0, 0 0; } 25 | 100% { background-position: 20px 20px, 0 20px; } 26 | } 27 | 28 | .nuclear-bg { 29 | background: 30 | radial-gradient(circle at 20% 50%, #ffd700 0%, transparent 50%), 31 | radial-gradient(circle at 80% 50%, #ff4500 0%, transparent 50%), 32 | repeating-linear-gradient( 33 | 45deg, 34 | #1a1a0d 0px, 35 | #1a1a0d 10px, 36 | #2a2a1a 10px, 37 | #2a2a1a 20px 38 | ); 39 | background-size: 100px 100px, 100px 100px, 20px 20px; 40 | } -------------------------------------------------------------------------------- /src/components/ServiceCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getServiceCardStyle } from '../themes/themeConfig'; 3 | 4 | const ServiceCard = ({ 5 | service: { name, url, icon_url, ip, port }, 6 | showDetails = true, 7 | mode = "light_mode", 8 | status, 9 | fontFamily = "Arial, sans-serif", 10 | fontSize = "14px", 11 | iconSize = "32px" 12 | }) => { 13 | // Get theme-specific card styling 14 | const cardStyle = getServiceCardStyle(mode, status); 15 | 16 | return ( 17 | 53 | ); 54 | }; 55 | 56 | export default ServiceCard; 57 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name _; 4 | 5 | root /usr/share/nginx/html; 6 | index index.html; 7 | 8 | # Add CORS headers 9 | add_header 'Access-Control-Allow-Origin' '*'; 10 | add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; 11 | add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization'; 12 | 13 | location / { 14 | try_files $uri $uri/ /index.html; 15 | } 16 | 17 | # Proxy API requests to the API server 18 | location /api/ { 19 | proxy_pass http://api:3001/api/; 20 | proxy_http_version 1.1; 21 | proxy_set_header Upgrade $http_upgrade; 22 | proxy_set_header Connection 'upgrade'; 23 | proxy_set_header Host $host; 24 | proxy_cache_bypass $http_upgrade; 25 | 26 | # Handle OPTIONS requests 27 | if ($request_method = 'OPTIONS') { 28 | add_header 'Access-Control-Allow-Origin' '*'; 29 | add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; 30 | add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization'; 31 | add_header 'Access-Control-Max-Age' 1728000; 32 | add_header 'Content-Type' 'text/plain charset=UTF-8'; 33 | add_header 'Content-Length' 0; 34 | return 204; 35 | } 36 | } 37 | 38 | # Enable gzip compression 39 | gzip on; 40 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 41 | } 42 | -------------------------------------------------------------------------------- /migrations/005_add_alerts.sql: -------------------------------------------------------------------------------- 1 | -- Create alert_settings table for global alert configuration 2 | CREATE TABLE IF NOT EXISTS alert_settings ( 3 | id SERIAL PRIMARY KEY, 4 | enabled BOOLEAN DEFAULT true, 5 | webhook_url TEXT, 6 | webhook_enabled BOOLEAN DEFAULT false, 7 | down_threshold_minutes INTEGER DEFAULT 5, 8 | paused_until TIMESTAMP NULL, 9 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 10 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 11 | ); 12 | 13 | -- Create alert_history table for tracking alert events 14 | CREATE TABLE IF NOT EXISTS alert_history ( 15 | id SERIAL PRIMARY KEY, 16 | service_id INTEGER REFERENCES services(id) ON DELETE CASCADE, 17 | service_name TEXT NOT NULL, 18 | service_ip TEXT, 19 | service_port INTEGER, 20 | alert_type VARCHAR(50) NOT NULL, -- 'service_down', 'service_up' 21 | message TEXT, 22 | webhook_sent BOOLEAN DEFAULT false, 23 | webhook_response TEXT, 24 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 25 | ); 26 | 27 | -- Add service_alert_settings to services table for per-service overrides 28 | ALTER TABLE services ADD COLUMN IF NOT EXISTS alert_enabled BOOLEAN DEFAULT true; 29 | ALTER TABLE services ADD COLUMN IF NOT EXISTS down_threshold_minutes INTEGER NULL; 30 | 31 | -- Create trigger for alert_settings updated_at 32 | CREATE OR REPLACE FUNCTION update_alert_settings_updated_at() 33 | RETURNS TRIGGER AS $$ 34 | BEGIN 35 | NEW.updated_at = CURRENT_TIMESTAMP; 36 | return NEW; 37 | END; 38 | $$ language 'plpgsql'; 39 | 40 | CREATE TRIGGER update_alert_settings_updated_at 41 | BEFORE UPDATE ON alert_settings 42 | FOR EACH ROW 43 | EXECUTE FUNCTION update_alert_settings_updated_at(); 44 | 45 | -- Insert default alert settings 46 | INSERT INTO alert_settings (enabled, down_threshold_minutes) 47 | SELECT true, 5 48 | WHERE NOT EXISTS (SELECT 1 FROM alert_settings); -------------------------------------------------------------------------------- /server/migrations/005_add_alerts.sql: -------------------------------------------------------------------------------- 1 | -- Create alert_settings table for global alert configuration 2 | CREATE TABLE IF NOT EXISTS alert_settings ( 3 | id SERIAL PRIMARY KEY, 4 | enabled BOOLEAN DEFAULT true, 5 | webhook_url TEXT, 6 | webhook_enabled BOOLEAN DEFAULT false, 7 | down_threshold_minutes INTEGER DEFAULT 5, 8 | paused_until TIMESTAMP NULL, 9 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 10 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 11 | ); 12 | 13 | -- Create alert_history table for tracking alert events 14 | CREATE TABLE IF NOT EXISTS alert_history ( 15 | id SERIAL PRIMARY KEY, 16 | service_id INTEGER REFERENCES services(id) ON DELETE CASCADE, 17 | service_name TEXT NOT NULL, 18 | service_ip TEXT, 19 | service_port INTEGER, 20 | alert_type VARCHAR(50) NOT NULL, -- 'service_down', 'service_up' 21 | message TEXT, 22 | webhook_sent BOOLEAN DEFAULT false, 23 | webhook_response TEXT, 24 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 25 | ); 26 | 27 | -- Add service_alert_settings to services table for per-service overrides 28 | ALTER TABLE services ADD COLUMN IF NOT EXISTS alert_enabled BOOLEAN DEFAULT true; 29 | ALTER TABLE services ADD COLUMN IF NOT EXISTS down_threshold_minutes INTEGER NULL; 30 | 31 | -- Create trigger for alert_settings updated_at 32 | CREATE OR REPLACE FUNCTION update_alert_settings_updated_at() 33 | RETURNS TRIGGER AS $$ 34 | BEGIN 35 | NEW.updated_at = CURRENT_TIMESTAMP; 36 | return NEW; 37 | END; 38 | $$ language 'plpgsql'; 39 | 40 | CREATE TRIGGER update_alert_settings_updated_at 41 | BEFORE UPDATE ON alert_settings 42 | FOR EACH ROW 43 | EXECUTE FUNCTION update_alert_settings_updated_at(); 44 | 45 | -- Insert default alert settings 46 | INSERT INTO alert_settings (enabled, down_threshold_minutes) 47 | SELECT true, 5 48 | WHERE NOT EXISTS (SELECT 1 FROM alert_settings); -------------------------------------------------------------------------------- /src/components/NavigationMenu.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Drawer, 4 | List, 5 | ListItem, 6 | ListItemText, 7 | ListItemButton, 8 | IconButton, 9 | Typography, 10 | Divider, 11 | Box 12 | } from '@mui/material'; 13 | import { Close as CloseIcon } from '@mui/icons-material'; 14 | import { Link, useLocation } from 'react-router-dom'; 15 | 16 | const NavigationMenu = ({ open, onClose, pages, themeStyles }) => { 17 | const location = useLocation(); 18 | 19 | return ( 20 | 32 | 33 | 34 | Navigation 35 | 36 | 40 | 41 | 42 | 43 | 44 | 45 | {pages.map((page) => ( 46 | 47 | 61 | 65 | 66 | 67 | ))} 68 | {pages.length === 0 && ( 69 | 70 | 74 | 75 | )} 76 | 77 | 78 | ); 79 | }; 80 | 81 | export default NavigationMenu; -------------------------------------------------------------------------------- /server/migrations/001_initial_schema.sql: -------------------------------------------------------------------------------- 1 | -- Create settings table for global configuration 2 | CREATE TABLE settings ( 3 | id SERIAL PRIMARY KEY, 4 | title TEXT NOT NULL DEFAULT 'New Homelab Dashboard', 5 | tab_title TEXT NOT NULL DEFAULT 'Homelab Dashboard', 6 | favicon_url TEXT, 7 | mode TEXT NOT NULL DEFAULT 'dark_mode', 8 | show_details BOOLEAN NOT NULL DEFAULT true, 9 | background_url TEXT, 10 | font_family TEXT NOT NULL DEFAULT 'Arial, sans-serif', 11 | font_size TEXT NOT NULL DEFAULT '14px', 12 | icon_size TEXT NOT NULL DEFAULT '32px', 13 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 14 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 15 | ); 16 | 17 | -- Create groups table 18 | CREATE TABLE groups ( 19 | id SERIAL PRIMARY KEY, 20 | title TEXT NOT NULL, 21 | display_order INTEGER NOT NULL DEFAULT 0, 22 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 23 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 24 | ); 25 | 26 | -- Create services table 27 | CREATE TABLE services ( 28 | id SERIAL PRIMARY KEY, 29 | group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, 30 | name TEXT NOT NULL, 31 | icon_url TEXT, 32 | ip TEXT, 33 | port INTEGER, 34 | url TEXT, 35 | display_order INTEGER NOT NULL DEFAULT 0, 36 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 37 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 38 | ); 39 | 40 | -- Create icons table for the top bar 41 | CREATE TABLE icons ( 42 | id SERIAL PRIMARY KEY, 43 | icon_url TEXT NOT NULL, 44 | link TEXT NOT NULL, 45 | alt TEXT, 46 | display_order INTEGER NOT NULL DEFAULT 0, 47 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 48 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 49 | ); 50 | 51 | -- Create function to update timestamps 52 | CREATE OR REPLACE FUNCTION update_updated_at_column() 53 | RETURNS TRIGGER AS $$ 54 | BEGIN 55 | NEW.updated_at = CURRENT_TIMESTAMP; 56 | RETURN NEW; 57 | END; 58 | $$ language 'plpgsql'; 59 | 60 | -- Create triggers for updating timestamps 61 | CREATE TRIGGER update_settings_updated_at 62 | BEFORE UPDATE ON settings 63 | FOR EACH ROW 64 | EXECUTE FUNCTION update_updated_at_column(); 65 | 66 | CREATE TRIGGER update_groups_updated_at 67 | BEFORE UPDATE ON groups 68 | FOR EACH ROW 69 | EXECUTE FUNCTION update_updated_at_column(); 70 | 71 | CREATE TRIGGER update_services_updated_at 72 | BEFORE UPDATE ON services 73 | FOR EACH ROW 74 | EXECUTE FUNCTION update_updated_at_column(); 75 | 76 | CREATE TRIGGER update_icons_updated_at 77 | BEFORE UPDATE ON icons 78 | FOR EACH ROW 79 | EXECUTE FUNCTION update_updated_at_column(); -------------------------------------------------------------------------------- /migrations/001_initial_schema.sql: -------------------------------------------------------------------------------- 1 | -- Create dashboard_config table 2 | CREATE TABLE dashboard_config ( 3 | id SERIAL PRIMARY KEY, 4 | title TEXT NOT NULL DEFAULT 'Homelab Dashboard', 5 | tab_title TEXT, 6 | favicon_url TEXT, 7 | mode TEXT DEFAULT 'light_mode', 8 | show_details BOOLEAN DEFAULT true, 9 | background_url TEXT, 10 | font_family TEXT DEFAULT 'Arial, sans-serif', 11 | font_size TEXT DEFAULT '14px', 12 | icon_size TEXT DEFAULT '32px', 13 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 14 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 15 | ); 16 | 17 | -- Create groups table 18 | CREATE TABLE groups ( 19 | id SERIAL PRIMARY KEY, 20 | title TEXT NOT NULL, 21 | display_order INTEGER NOT NULL, 22 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 23 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 24 | ); 25 | 26 | -- Create services table 27 | CREATE TABLE services ( 28 | id SERIAL PRIMARY KEY, 29 | group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE, 30 | name TEXT NOT NULL, 31 | icon_url TEXT, 32 | ip TEXT NOT NULL, 33 | port INTEGER NOT NULL, 34 | url TEXT NOT NULL, 35 | display_order INTEGER NOT NULL, 36 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 37 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 38 | ); 39 | 40 | -- Create bar_icons table 41 | CREATE TABLE bar_icons ( 42 | id SERIAL PRIMARY KEY, 43 | icon_url TEXT NOT NULL, 44 | link TEXT NOT NULL, 45 | alt TEXT, 46 | display_order INTEGER NOT NULL, 47 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 48 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 49 | ); 50 | 51 | -- Add triggers to update updated_at timestamp 52 | CREATE OR REPLACE FUNCTION update_updated_at_column() 53 | RETURNS TRIGGER AS $$ 54 | BEGIN 55 | NEW.updated_at = CURRENT_TIMESTAMP; 56 | RETURN NEW; 57 | END; 58 | $$ language 'plpgsql'; 59 | 60 | CREATE TRIGGER update_dashboard_config_updated_at 61 | BEFORE UPDATE ON dashboard_config 62 | FOR EACH ROW 63 | EXECUTE FUNCTION update_updated_at_column(); 64 | 65 | CREATE TRIGGER update_groups_updated_at 66 | BEFORE UPDATE ON groups 67 | FOR EACH ROW 68 | EXECUTE FUNCTION update_updated_at_column(); 69 | 70 | CREATE TRIGGER update_services_updated_at 71 | BEFORE UPDATE ON services 72 | FOR EACH ROW 73 | EXECUTE FUNCTION update_updated_at_column(); 74 | 75 | CREATE TRIGGER update_bar_icons_updated_at 76 | BEFORE UPDATE ON bar_icons 77 | FOR EACH ROW 78 | EXECUTE FUNCTION update_updated_at_column(); 79 | 80 | -- Insert default dashboard configuration 81 | INSERT INTO dashboard_config (title, mode, show_details) 82 | VALUES ('Homelab Dashboard', 'light_mode', true); -------------------------------------------------------------------------------- /src/components/widgets/WidgetContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '@mui/material'; 3 | import WeatherWidget from './WeatherWidget'; 4 | import SunPositionWidget from './SunPositionWidget'; 5 | import DateTimeWidget from './DateTimeWidget'; 6 | 7 | const WidgetContainer = ({ widgets, themeStyles, displayType = 'all' }) => { 8 | const renderWidget = (widget, isSmall = false) => { 9 | const scale = isSmall ? 0.5 : 1; 10 | const transform = isSmall ? 'scale(0.5)' : 'none'; 11 | const transformOrigin = isSmall ? 'center' : 'initial'; 12 | 13 | const widgetProps = { 14 | key: widget.id, 15 | config: widget.config, 16 | themeStyles: themeStyles, 17 | isSmall: isSmall 18 | }; 19 | 20 | let widgetComponent; 21 | switch (widget.type) { 22 | case 'weather': 23 | widgetComponent = ; 24 | break; 25 | case 'sun_position': 26 | widgetComponent = ; 27 | break; 28 | case 'datetime': 29 | widgetComponent = ; 30 | break; 31 | default: 32 | return null; 33 | } 34 | 35 | if (isSmall) { 36 | return ( 37 | 45 | {widgetComponent} 46 | 47 | ); 48 | } 49 | 50 | return widgetComponent; 51 | }; 52 | 53 | if (!widgets || widgets.length === 0) { 54 | return null; 55 | } 56 | 57 | const enabledWidgets = widgets 58 | .filter(widget => widget.enabled !== false) 59 | .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); 60 | 61 | if (displayType === 'datetime') { 62 | // Only show datetime widgets 63 | const datetimeWidgets = enabledWidgets.filter(widget => widget.type === 'datetime'); 64 | 65 | if (datetimeWidgets.length === 0) return null; 66 | 67 | return ( 68 | 75 | {datetimeWidgets.map(widget => renderWidget(widget, false))} 76 | 77 | ); 78 | } 79 | 80 | if (displayType === 'other') { 81 | // Only show non-datetime widgets, made smaller 82 | const otherWidgets = enabledWidgets.filter(widget => widget.type !== 'datetime'); 83 | 84 | if (otherWidgets.length === 0) return null; 85 | 86 | return ( 87 | 96 | {otherWidgets.map(widget => renderWidget(widget, true))} 97 | 98 | ); 99 | } 100 | 101 | // Legacy: show all widgets (fallback) 102 | return ( 103 | 112 | {enabledWidgets.map(widget => renderWidget(widget, false))} 113 | 114 | ); 115 | }; 116 | 117 | export default WidgetContainer; -------------------------------------------------------------------------------- /test-alerts.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Simple test script for alert API endpoints 4 | // Run with: node test-alerts.js 5 | 6 | const http = require('http'); 7 | 8 | const baseURL = 'http://localhost:3001'; 9 | 10 | const makeRequest = (method, path, data = null) => { 11 | return new Promise((resolve, reject) => { 12 | const url = new URL(path, baseURL); 13 | const options = { 14 | hostname: url.hostname, 15 | port: url.port, 16 | path: url.pathname, 17 | method: method, 18 | headers: { 19 | 'Content-Type': 'application/json' 20 | } 21 | }; 22 | 23 | const req = http.request(options, (res) => { 24 | let body = ''; 25 | res.on('data', (chunk) => body += chunk); 26 | res.on('end', () => { 27 | try { 28 | const result = JSON.parse(body); 29 | resolve({ status: res.statusCode, data: result }); 30 | } catch (e) { 31 | resolve({ status: res.statusCode, data: body }); 32 | } 33 | }); 34 | }); 35 | 36 | req.on('error', reject); 37 | 38 | if (data) { 39 | req.write(JSON.stringify(data)); 40 | } 41 | 42 | req.end(); 43 | }); 44 | }; 45 | 46 | const runTests = async () => { 47 | console.log('Testing Alert API Endpoints...\n'); 48 | 49 | try { 50 | // Test 1: Get alert settings 51 | console.log('1. Testing GET /api/alert-settings'); 52 | const getResult = await makeRequest('GET', '/api/alert-settings'); 53 | console.log(` Status: ${getResult.status}`); 54 | console.log(` Data:`, getResult.data); 55 | console.log(''); 56 | 57 | // Test 2: Update alert settings 58 | console.log('2. Testing PUT /api/alert-settings'); 59 | const testSettings = { 60 | enabled: true, 61 | webhook_url: 'https://example.com/webhook', 62 | webhook_enabled: true, 63 | down_threshold_minutes: 10, 64 | paused_until: null 65 | }; 66 | 67 | const putResult = await makeRequest('PUT', '/api/alert-settings', testSettings); 68 | console.log(` Status: ${putResult.status}`); 69 | console.log(` Data:`, putResult.data); 70 | console.log(''); 71 | 72 | // Test 3: Get alert history 73 | console.log('3. Testing GET /api/alert-history'); 74 | const historyResult = await makeRequest('GET', '/api/alert-history'); 75 | console.log(` Status: ${historyResult.status}`); 76 | console.log(` Data:`, Array.isArray(historyResult.data) ? `Array with ${historyResult.data.length} items` : historyResult.data); 77 | console.log(''); 78 | 79 | // Test 4: Test webhook 80 | console.log('4. Testing POST /api/test-webhook'); 81 | const webhookTest = { 82 | webhook_url: 'https://httpbin.org/post' // This will always accept the request 83 | }; 84 | 85 | const webhookResult = await makeRequest('POST', '/api/test-webhook', webhookTest); 86 | console.log(` Status: ${webhookResult.status}`); 87 | console.log(` Data:`, webhookResult.data); 88 | console.log(''); 89 | 90 | console.log('All tests completed!'); 91 | 92 | } catch (error) { 93 | console.error('Test failed:', error.message); 94 | } 95 | }; 96 | 97 | // Check if API server is running 98 | makeRequest('GET', '/api/health') 99 | .then((result) => { 100 | if (result.status === 200) { 101 | console.log('API server is running. Starting tests...\n'); 102 | runTests(); 103 | } else { 104 | console.log('API server health check failed. Is the server running?'); 105 | } 106 | }) 107 | .catch((error) => { 108 | console.log('Cannot connect to API server. Please ensure Docker containers are running.'); 109 | console.log('Error:', error.message); 110 | }); -------------------------------------------------------------------------------- /src/themes/backgrounds.css: -------------------------------------------------------------------------------- 1 | /* Theme Background Effects */ 2 | 3 | /* Retro Theme */ 4 | .retro-bg { 5 | background: 6 | radial-gradient(ellipse at center, #f5f5dc 0%, #daa520 100%), 7 | repeating-linear-gradient( 8 | 45deg, 9 | transparent, 10 | transparent 35px, 11 | rgba(218, 165, 32, 0.1) 35px, 12 | rgba(218, 165, 32, 0.1) 70px 13 | ); 14 | background-attachment: fixed; 15 | } 16 | 17 | .retro-bg::before { 18 | content: ''; 19 | position: fixed; 20 | top: 0; 21 | left: 0; 22 | width: 100%; 23 | height: 100%; 24 | background: 25 | repeating-linear-gradient( 26 | 0deg, 27 | transparent 0px, 28 | rgba(0, 0, 0, 0.03) 1px, 29 | rgba(0, 0, 0, 0.03) 2px, 30 | transparent 2px, 31 | transparent 4px 32 | ); 33 | pointer-events: none; 34 | z-index: -1; 35 | } 36 | 37 | /* Matrix Theme */ 38 | .matrix-bg { 39 | background: #000000; 40 | position: relative; 41 | } 42 | 43 | .matrix-bg::before { 44 | content: ''; 45 | position: fixed; 46 | top: 0; 47 | left: 0; 48 | width: 100%; 49 | height: 100%; 50 | background-image: 51 | linear-gradient(90deg, transparent 0%, rgba(0, 255, 0, 0.03) 50%, transparent 100%), 52 | repeating-linear-gradient( 53 | 0deg, 54 | transparent 0px, 55 | rgba(0, 255, 0, 0.05) 1px, 56 | transparent 2px 57 | ); 58 | animation: matrix-rain 20s linear infinite; 59 | pointer-events: none; 60 | z-index: -1; 61 | } 62 | 63 | @keyframes matrix-rain { 64 | 0% { transform: translateY(-100vh); } 65 | 100% { transform: translateY(100vh); } 66 | } 67 | 68 | /* Nuclear Theme */ 69 | .nuclear-bg { 70 | background: 71 | radial-gradient(ellipse at center, #2d3748 0%, #1a202c 70%, #000000 100%), 72 | repeating-linear-gradient( 73 | 45deg, 74 | transparent, 75 | transparent 20px, 76 | rgba(255, 255, 0, 0.05) 20px, 77 | rgba(255, 255, 0, 0.05) 40px 78 | ); 79 | position: relative; 80 | } 81 | 82 | .nuclear-bg::before { 83 | content: ''; 84 | position: fixed; 85 | top: 0; 86 | left: 0; 87 | width: 100%; 88 | height: 100%; 89 | background: 90 | radial-gradient(circle at 20% 20%, rgba(255, 255, 0, 0.1) 0%, transparent 50%), 91 | radial-gradient(circle at 80% 80%, rgba(255, 165, 0, 0.1) 0%, transparent 50%), 92 | radial-gradient(circle at 40% 60%, rgba(255, 0, 0, 0.05) 0%, transparent 50%); 93 | animation: nuclear-glow 10s ease-in-out infinite alternate; 94 | pointer-events: none; 95 | z-index: -1; 96 | } 97 | 98 | @keyframes nuclear-glow { 99 | 0% { opacity: 0.3; } 100 | 100% { opacity: 0.6; } 101 | } 102 | 103 | /* High Contrast Accessibility */ 104 | .high-contrast-light-bg { 105 | background: #ffffff; 106 | border: 4px solid #000000; 107 | box-shadow: inset 0 0 0 2px #ffffff, inset 0 0 0 6px #000000; 108 | } 109 | 110 | .high-contrast-light-bg * { 111 | border-width: 4px !important; 112 | font-weight: bold !important; 113 | } 114 | 115 | .high-contrast-dark-bg { 116 | background: #000000; 117 | border: 4px solid #ffffff; 118 | box-shadow: inset 0 0 0 2px #000000, inset 0 0 0 6px #ffffff; 119 | } 120 | 121 | .high-contrast-dark-bg * { 122 | border-width: 4px !important; 123 | font-weight: bold !important; 124 | } 125 | 126 | /* Animation for interactive elements */ 127 | .theme-card-hover { 128 | transition: all 0.3s ease; 129 | } 130 | 131 | .theme-card-hover:hover { 132 | transform: translateY(-2px); 133 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 134 | } 135 | 136 | /* Service Mode specific animations */ 137 | .service-card-pulse { 138 | animation: service-pulse 2s ease-in-out infinite; 139 | } 140 | 141 | @keyframes service-pulse { 142 | 0%, 100% { opacity: 1; } 143 | 50% { opacity: 0.8; } 144 | } -------------------------------------------------------------------------------- /src/components/widgets/WeatherWidget.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Card, CardContent, Typography, Box } from '@mui/material'; 3 | import { WbSunny, Cloud, CloudQueue, Grain, Thunderstorm } from '@mui/icons-material'; 4 | 5 | const WeatherWidget = ({ config, themeStyles, isSmall = false }) => { 6 | const [weather, setWeather] = useState(null); 7 | const [loading, setLoading] = useState(true); 8 | const [error, setError] = useState(null); 9 | 10 | useEffect(() => { 11 | const fetchWeather = async () => { 12 | if (!config.apiKey || !config.location) { 13 | setError('Weather API key and location required'); 14 | setLoading(false); 15 | return; 16 | } 17 | 18 | try { 19 | const response = await fetch( 20 | `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(config.location)}&appid=${config.apiKey}&units=${config.units || 'metric'}` 21 | ); 22 | 23 | if (!response.ok) { 24 | throw new Error('Failed to fetch weather data'); 25 | } 26 | 27 | const data = await response.json(); 28 | setWeather(data); 29 | setError(null); 30 | } catch (err) { 31 | setError(err.message); 32 | } finally { 33 | setLoading(false); 34 | } 35 | }; 36 | 37 | fetchWeather(); 38 | const interval = setInterval(fetchWeather, 600000); // Update every 10 minutes 39 | 40 | return () => clearInterval(interval); 41 | }, [config]); 42 | 43 | const getWeatherIcon = (weather) => { 44 | const main = weather?.weather?.[0]?.main?.toLowerCase(); 45 | switch (main) { 46 | case 'clear': return ; 47 | case 'clouds': return ; 48 | case 'rain': return ; 49 | case 'thunderstorm': return ; 50 | default: return ; 51 | } 52 | }; 53 | 54 | const getTemperatureUnit = () => { 55 | return config.units === 'imperial' ? '°F' : '°C'; 56 | }; 57 | 58 | if (loading) { 59 | return ( 60 | 61 | 62 | Loading weather... 63 | 64 | 65 | ); 66 | } 67 | 68 | if (error) { 69 | return ( 70 | 71 | 72 | Weather Error: {error} 73 | 74 | 75 | ); 76 | } 77 | 78 | return ( 79 | 85 | 86 | 87 | 88 | 89 | {weather?.name} 90 | 91 | 92 | {Math.round(weather?.main?.temp)}{getTemperatureUnit()} 93 | 94 | 95 | {weather?.weather?.[0]?.description} 96 | 97 | 98 | Feels like {Math.round(weather?.main?.feels_like)}{getTemperatureUnit()} 99 | 100 | 101 | 102 | {getWeatherIcon(weather)} 103 | 104 | 105 | 106 | 107 | Humidity: {weather?.main?.humidity}% 108 | 109 | 110 | Wind: {weather?.wind?.speed} {config.units === 'imperial' ? 'mph' : 'm/s'} 111 | 112 | 113 | 114 | 115 | ); 116 | }; 117 | 118 | export default WeatherWidget; -------------------------------------------------------------------------------- /src/components/widgets/DateTimeWidget.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Card, CardContent, Typography, Box } from '@mui/material'; 3 | import { AccessTime, Today } from '@mui/icons-material'; 4 | 5 | const DateTimeWidget = ({ config, themeStyles, isSmall = false }) => { 6 | const [currentTime, setCurrentTime] = useState(new Date()); 7 | 8 | useEffect(() => { 9 | const timer = setInterval(() => { 10 | setCurrentTime(new Date()); 11 | }, 1000); 12 | 13 | return () => clearInterval(timer); 14 | }, []); 15 | 16 | const formatTime = () => { 17 | const options = { 18 | hour: '2-digit', 19 | minute: '2-digit', 20 | second: config.showSeconds ? '2-digit' : undefined, 21 | hour12: config.format12Hour || false, 22 | timeZone: config.timezone || undefined 23 | }; 24 | return currentTime.toLocaleTimeString(config.locale || 'en-US', options); 25 | }; 26 | 27 | const formatDate = () => { 28 | const options = { 29 | weekday: config.showWeekday ? 'long' : undefined, 30 | year: 'numeric', 31 | month: config.dateFormat === 'short' ? 'short' : 'long', 32 | day: 'numeric', 33 | timeZone: config.timezone || undefined 34 | }; 35 | return currentTime.toLocaleDateString(config.locale || 'en-US', options); 36 | }; 37 | 38 | const getGreeting = () => { 39 | const hour = currentTime.getHours(); 40 | if (hour < 6) return 'Good Night'; 41 | if (hour < 12) return 'Good Morning'; 42 | if (hour < 17) return 'Good Afternoon'; 43 | if (hour < 21) return 'Good Evening'; 44 | return 'Good Night'; 45 | }; 46 | 47 | return ( 48 | 55 | 56 | 63 | {/* Time Section */} 64 | 65 | 66 | 67 | 77 | {formatTime()} 78 | 79 | {config.showGreeting && ( 80 | 81 | {getGreeting()} 82 | 83 | )} 84 | 85 | 86 | 87 | {/* Vertical Divider - Hidden on small screens */} 88 | 97 | 98 | {/* Date Section */} 99 | 100 | 101 | 102 | 111 | {formatDate()} 112 | 113 | {config.showTimezone && ( 114 | 115 | {config.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone} 116 | 117 | )} 118 | 119 | 120 | 121 | 122 | 123 | ); 124 | }; 125 | 126 | export default DateTimeWidget; -------------------------------------------------------------------------------- /src/themes/themeConfig.js: -------------------------------------------------------------------------------- 1 | // Theme configuration for DitDashDot 2 | // This module defines all available themes and their properties 3 | 4 | export const THEMES = { 5 | light_mode: { 6 | name: "Light Mode", 7 | backgroundColor: "#fff", 8 | color: "#1a1616ff", 9 | backgroundImage: null, 10 | cardShadow: "0 2px 8px rgba(0,0,0,0.1)", 11 | border: "none" 12 | }, 13 | 14 | dark_mode: { 15 | name: "Dark Mode", 16 | backgroundColor: "#222", 17 | color: "#fff", 18 | backgroundImage: null, 19 | cardShadow: "0 2px 8px rgba(0,0,0,0.3)", 20 | border: "none" 21 | }, 22 | 23 | trans_light: { 24 | name: "Transparent Light", 25 | backgroundColor: "transparent", 26 | color: "#000", 27 | backgroundImage: null, 28 | cardShadow: "0 2px 8px rgba(0,0,0,0.1)", 29 | border: "none" 30 | }, 31 | 32 | trans_dark: { 33 | name: "Transparent Dark", 34 | backgroundColor: "transparent", 35 | color: "#fff", 36 | backgroundImage: null, 37 | cardShadow: "0 2px 8px rgba(0,0,0,0.3)", 38 | border: "none" 39 | }, 40 | 41 | service_mode_light: { 42 | name: "Service Mode Light", 43 | backgroundColor: "#fff", 44 | color: "#1a1616ff", 45 | backgroundImage: null, 46 | cardShadow: "0 2px 8px rgba(0,0,0,0.1)", 47 | border: "none", 48 | serviceColors: { 49 | unknown: { background: "#bbb", color: "#222" }, 50 | up: { background: "#82ff82ff", color: "#fff" }, 51 | down: { background: "#ff5959ff", color: "#fff" } 52 | } 53 | }, 54 | 55 | service_mode_dark: { 56 | name: "Service Mode Dark", 57 | backgroundColor: "#222", 58 | color: "#fff", 59 | backgroundImage: null, 60 | cardShadow: "0 2px 8px rgba(0,0,0,0.3)", 61 | border: "none", 62 | serviceColors: { 63 | unknown: { background: "#555", color: "#ccc" }, 64 | up: { background: "#2d5a2d", color: "#90ee90" }, 65 | down: { background: "#5a2d2d", color: "#ffb3b3" } 66 | } 67 | }, 68 | 69 | retro: { 70 | name: "Retro", 71 | backgroundColor: "#f2f0e6", 72 | color: "#2c1810", 73 | backgroundImage: "/themes/retro-bg.jpg", 74 | cardShadow: "2px 2px 4px rgba(0,0,0,0.3)", 75 | border: "2px solid #8b4513", 76 | cardStyle: { 77 | fontFamily: "'Courier New', monospace", 78 | borderRadius: "0px", 79 | background: "linear-gradient(145deg, #f5f5dc, #ddd8c0)" 80 | } 81 | }, 82 | 83 | matrix: { 84 | name: "Matrix", 85 | backgroundColor: "#000000", 86 | color: "#00ff00", 87 | backgroundImage: "/themes/matrix-bg.gif", 88 | cardShadow: "0 0 10px rgba(0,255,0,0.3)", 89 | border: "1px solid #00ff00", 90 | cardStyle: { 91 | fontFamily: "'Courier New', monospace", 92 | borderRadius: "0px", 93 | background: "rgba(0,0,0,0.8)", 94 | border: "1px solid #00ff00", 95 | boxShadow: "0 0 10px rgba(0,255,0,0.2), inset 0 0 10px rgba(0,255,0,0.1)" 96 | }, 97 | inputStyle: { 98 | background: "rgba(0,0,0,0.8)", 99 | border: "1px solid #00ff00", 100 | color: "#00ff00" 101 | } 102 | }, 103 | 104 | nuclear: { 105 | name: "Nuclear", 106 | backgroundColor: "#1a1a0d", 107 | color: "#ffff99", 108 | backgroundImage: "/themes/nuclear-bg.jpg", 109 | cardShadow: "0 0 15px rgba(255,215,0,0.3)", 110 | border: "2px solid #ffd700", 111 | cardStyle: { 112 | fontFamily: "'Roboto Condensed', sans-serif", 113 | borderRadius: "5px", 114 | background: "linear-gradient(145deg, #2a2a1a, #1a1a0d)", 115 | border: "2px solid #ffd700", 116 | boxShadow: "0 0 15px rgba(255,215,0,0.2), inset 0 0 10px rgba(255,215,0,0.1)" 117 | }, 118 | inputStyle: { 119 | background: "rgba(26,26,13,0.9)", 120 | border: "1px solid #ffd700", 121 | color: "#ffff99" 122 | } 123 | }, 124 | 125 | high_contrast_light: { 126 | name: "High Contrast Light", 127 | backgroundColor: "#ffffff", 128 | color: "#000000", 129 | backgroundImage: null, 130 | cardShadow: "none", 131 | border: "4px solid #000000", 132 | cardStyle: { 133 | borderRadius: "8px", 134 | background: "#ffffff", 135 | border: "4px solid #000000", 136 | boxShadow: "none", 137 | fontWeight: "bold" 138 | }, 139 | inputStyle: { 140 | background: "#ffffff", 141 | border: "4px solid #000000", 142 | color: "#000000", 143 | fontWeight: "bold" 144 | } 145 | }, 146 | 147 | high_contrast_dark: { 148 | name: "High Contrast Dark", 149 | backgroundColor: "#000000", 150 | color: "#ffffff", 151 | backgroundImage: null, 152 | cardShadow: "none", 153 | border: "4px solid #ffffff", 154 | cardStyle: { 155 | borderRadius: "8px", 156 | background: "#000000", 157 | border: "4px solid #ffffff", 158 | boxShadow: "none", 159 | fontWeight: "bold" 160 | }, 161 | inputStyle: { 162 | background: "#000000", 163 | border: "4px solid #ffffff", 164 | color: "#ffffff", 165 | fontWeight: "bold" 166 | } 167 | } 168 | }; 169 | 170 | // Helper function to get theme configuration 171 | export const getTheme = (themeName) => { 172 | return THEMES[themeName] || THEMES.light_mode; 173 | }; 174 | 175 | // Helper function to get all theme names for dropdown 176 | export const getThemeNames = () => { 177 | return Object.keys(THEMES); 178 | }; 179 | 180 | // Helper function to get service card styles for service mode themes 181 | export const getServiceCardStyle = (themeName, status) => { 182 | const theme = getTheme(themeName); 183 | 184 | // Handle service mode themes 185 | if (theme.serviceColors) { 186 | if (status === undefined) { 187 | return theme.serviceColors.unknown; 188 | } else if (status) { 189 | return theme.serviceColors.up; 190 | } else { 191 | return theme.serviceColors.down; 192 | } 193 | } 194 | 195 | // Default card styling 196 | return { 197 | background: theme.backgroundColor, 198 | color: theme.color 199 | }; 200 | }; -------------------------------------------------------------------------------- /src/components/widgets/SunPositionWidget.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Card, CardContent, Typography, Box, LinearProgress } from '@mui/material'; 3 | import { WbSunny, Brightness3 } from '@mui/icons-material'; 4 | 5 | const SunPositionWidget = ({ config, themeStyles, isSmall = false }) => { 6 | const [sunData, setSunData] = useState(null); 7 | const [loading, setLoading] = useState(true); 8 | const [error, setError] = useState(null); 9 | 10 | useEffect(() => { 11 | const fetchSunData = async () => { 12 | if (!config.latitude || !config.longitude) { 13 | setError('Latitude and longitude required'); 14 | setLoading(false); 15 | return; 16 | } 17 | 18 | try { 19 | const response = await fetch( 20 | `https://api.sunrise-sunset.org/json?lat=${config.latitude}&lng=${config.longitude}&formatted=0` 21 | ); 22 | 23 | if (!response.ok) { 24 | throw new Error('Failed to fetch sun position data'); 25 | } 26 | 27 | const data = await response.json(); 28 | if (data.status === 'OK') { 29 | setSunData(data.results); 30 | setError(null); 31 | } else { 32 | throw new Error('Invalid location data'); 33 | } 34 | } catch (err) { 35 | setError(err.message); 36 | } finally { 37 | setLoading(false); 38 | } 39 | }; 40 | 41 | fetchSunData(); 42 | const interval = setInterval(fetchSunData, 3600000); // Update every hour 43 | 44 | return () => clearInterval(interval); 45 | }, [config]); 46 | 47 | const calculateSunProgress = () => { 48 | if (!sunData) return 0; 49 | 50 | const now = new Date(); 51 | const sunrise = new Date(sunData.sunrise); 52 | const sunset = new Date(sunData.sunset); 53 | 54 | if (now < sunrise) return 0; // Before sunrise 55 | if (now > sunset) return 100; // After sunset 56 | 57 | const totalDaylight = sunset - sunrise; 58 | const currentProgress = now - sunrise; 59 | return (currentProgress / totalDaylight) * 100; 60 | }; 61 | 62 | const isDaytime = () => { 63 | if (!sunData) return true; 64 | const now = new Date(); 65 | const sunrise = new Date(sunData.sunrise); 66 | const sunset = new Date(sunData.sunset); 67 | return now >= sunrise && now <= sunset; 68 | }; 69 | 70 | const formatTime = (dateString) => { 71 | return new Date(dateString).toLocaleTimeString([], { 72 | hour: '2-digit', 73 | minute: '2-digit', 74 | timeZone: config.timezone || 'local' 75 | }); 76 | }; 77 | 78 | const getDaylightDuration = () => { 79 | if (!sunData) return ''; 80 | const sunrise = new Date(sunData.sunrise); 81 | const sunset = new Date(sunData.sunset); 82 | const duration = sunset - sunrise; 83 | const hours = Math.floor(duration / (1000 * 60 * 60)); 84 | const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60)); 85 | return `${hours}h ${minutes}m`; 86 | }; 87 | 88 | if (loading) { 89 | return ( 90 | 91 | 92 | Loading sun data... 93 | 94 | 95 | ); 96 | } 97 | 98 | if (error) { 99 | return ( 100 | 101 | 102 | Sun Position Error: {error} 103 | 104 | 105 | ); 106 | } 107 | 108 | const progress = calculateSunProgress(); 109 | const isDay = isDaytime(); 110 | 111 | return ( 112 | 118 | 119 | 120 | 121 | Sun Position 122 | 123 | 124 | {isDay ? : } 125 | 126 | 127 | 128 | 129 | 130 | {isDay ? 'Daytime Progress' : 'Sun has set'} 131 | 132 | 144 | 145 | {Math.round(progress)}% complete 146 | 147 | 148 | 149 | 150 | 151 | 152 | Sunrise 153 | 154 | 155 | {formatTime(sunData.sunrise)} 156 | 157 | 158 | 159 | 160 | Sunset 161 | 162 | 163 | {formatTime(sunData.sunset)} 164 | 165 | 166 | 167 | 168 | 169 | 170 | Daylight Duration: {getDaylightDuration()} 171 | 172 | 173 | 174 | 175 | ); 176 | }; 177 | 178 | export default SunPositionWidget; -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { BrowserRouter as Router, Routes, Route, Link, useParams, Navigate } from 'react-router-dom'; 3 | import ServiceCard from './components/ServiceCard'; 4 | import ConfigEditor from './components/ConfigEditor'; 5 | import { Box, CircularProgress, Typography, IconButton, Drawer, List, ListItem, ListItemText, AppBar, Toolbar, Alert } from '@mui/material'; 6 | import { Settings as SettingsIcon, Menu as MenuIcon } from '@mui/icons-material'; 7 | import ConfigurationPage from './components/config/ConfigurationPage'; 8 | import NavigationMenu from './components/NavigationMenu'; 9 | import RootRedirect from './components/RootRedirect'; 10 | import WidgetContainer from './components/widgets/WidgetContainer'; 11 | import { getTheme, getServiceCardStyle } from './themes/themeConfig'; 12 | import './themes/backgrounds.css'; 13 | 14 | const Dashboard = () => { 15 | const { pageId } = useParams(); 16 | const [pages, setPages] = useState([]); 17 | const [currentPage, setCurrentPage] = useState(null); 18 | const [groups, setGroups] = useState([]); 19 | const [widgets, setWidgets] = useState([]); 20 | const [searchTerm, setSearchTerm] = useState(""); 21 | const [dashboardTitle, setDashboardTitle] = useState("Homelab Dashboard"); 22 | const [tabTitle, setTabTitle] = useState("Homelab Dashboard"); 23 | const [faviconUrl, setFaviconUrl] = useState(""); 24 | const [mode, setMode] = useState("light_mode"); 25 | const [showDetails, setShowDetails] = useState(true); 26 | const [statuses, setStatuses] = useState({}); 27 | const [backgroundUrl, setBackgroundUrl] = useState(""); 28 | const [barIcons, setBarIcons] = useState([]); // new state for bar icons 29 | const [fontFamily, setFontFamily] = useState("Arial, sans-serif"); 30 | const [fontSize, setFontSize] = useState("14px"); 31 | const [iconSize, setIconSize] = useState("32px"); 32 | const [drawerOpen, setDrawerOpen] = useState(false); 33 | const [alertSettings, setAlertSettings] = useState({ enabled: false }); 34 | 35 | // Effect to update document title and favicon 36 | useEffect(() => { 37 | // Update document title 38 | document.title = tabTitle; 39 | 40 | // Update favicon 41 | if (faviconUrl) { 42 | const link = document.querySelector("link[rel*='icon']") || document.createElement('link'); 43 | link.type = 'image/x-icon'; 44 | link.rel = 'shortcut icon'; 45 | link.href = faviconUrl; 46 | document.getElementsByTagName('head')[0].appendChild(link); 47 | } 48 | }, [tabTitle, faviconUrl]); 49 | 50 | const [loading, setLoading] = useState(true); 51 | const [error, setError] = useState(null); 52 | 53 | useEffect(() => { 54 | const loadConfig = async () => { 55 | try { 56 | setLoading(true); 57 | const [settingsRes, groupsRes, servicesRes, iconsRes, pagesRes, widgetsRes, alertSettingsRes] = await Promise.all([ 58 | fetch('/api/settings'), 59 | fetch('/api/groups'), 60 | fetch('/api/services'), 61 | fetch('/api/icons'), 62 | fetch('/api/pages'), 63 | fetch('/api/widgets'), 64 | fetch('/api/alert-settings').catch(() => ({ json: () => Promise.resolve({}) })) 65 | ]); 66 | 67 | const [settingsData, groupsData, servicesData, iconsData, pagesData, widgetsData, alertSettingsData] = await Promise.all([ 68 | settingsRes.json(), 69 | groupsRes.json(), 70 | servicesRes.json(), 71 | iconsRes.json(), 72 | pagesRes.json(), 73 | widgetsRes.json(), 74 | alertSettingsRes.json() 75 | ]); 76 | 77 | console.log('Loaded data:', { settingsData, groupsData, servicesData, iconsData, pagesData, widgetsData }); 78 | 79 | // Set pages 80 | setPages((pagesData || []).sort((a, b) => a.display_order - b.display_order)); 81 | 82 | // Find current page 83 | let targetPageId = pageId; 84 | if (!targetPageId && pagesData && pagesData.length > 0) { 85 | targetPageId = pagesData.sort((a, b) => a.display_order - b.display_order)[0].id.toString(); 86 | } 87 | 88 | const currentPageData = pagesData ? pagesData.find(p => p.id.toString() === targetPageId) : null; 89 | setCurrentPage(currentPageData); 90 | 91 | if (settingsData) { 92 | setDashboardTitle(settingsData.title || "Homelab Dashboard"); 93 | setTabTitle(settingsData.tab_title || "Homelab Dashboard"); 94 | setFaviconUrl(settingsData.favicon_url || ""); 95 | setMode(settingsData.mode || "light_mode"); 96 | setShowDetails(settingsData.show_details !== false); 97 | setBackgroundUrl(settingsData.background_url || ""); 98 | setFontFamily(settingsData.font_family || "Arial, sans-serif"); 99 | setFontSize(settingsData.font_size || "14px"); 100 | setIconSize(settingsData.icon_size || "32px"); 101 | } 102 | 103 | // Filter groups, services, and widgets by current page 104 | let filteredGroupsData = groupsData || []; 105 | let filteredServicesData = servicesData || []; 106 | let filteredWidgetsData = widgetsData || []; 107 | 108 | if (currentPageData) { 109 | filteredGroupsData = (groupsData || []).filter(group => group.page_id === currentPageData.id); 110 | filteredServicesData = (servicesData || []).filter(service => { 111 | const serviceGroup = (groupsData || []).find(g => g.id === service.group_id); 112 | return serviceGroup && serviceGroup.page_id === currentPageData.id; 113 | }); 114 | filteredWidgetsData = (widgetsData || []).filter(widget => widget.page_id === currentPageData.id); 115 | } 116 | 117 | // Group services by their group_id 118 | const groupedServices = {}; 119 | filteredServicesData.forEach(service => { 120 | if (!groupedServices[service.group_id]) { 121 | groupedServices[service.group_id] = []; 122 | } 123 | groupedServices[service.group_id].push(service); 124 | }); 125 | 126 | // Attach services to their groups 127 | const groupsWithServices = filteredGroupsData.map(group => ({ 128 | ...group, 129 | services: groupedServices[group.id] || [] 130 | })); 131 | 132 | setGroups(groupsWithServices); 133 | setWidgets(filteredWidgetsData); 134 | setBarIcons(iconsData || []); 135 | setAlertSettings(alertSettingsData || { enabled: false }); 136 | setError(null); 137 | } catch (err) { 138 | console.error("Failed to load configuration:", err); 139 | setError(err.message); 140 | } finally { 141 | setLoading(false); 142 | } 143 | }; 144 | loadConfig(); 145 | }, [pageId]); 146 | 147 | useEffect(() => { 148 | const intervalRef = { current: null }; 149 | 150 | const pingServices = async () => { 151 | try { 152 | // Collect all services that have IP and port 153 | const servicesToPing = []; 154 | for (const group of groups) { 155 | for (const service of group.services || []) { 156 | if (service.ip && service.port) { 157 | servicesToPing.push(service); 158 | } 159 | } 160 | } 161 | 162 | if (servicesToPing.length === 0) { 163 | setStatuses({}); 164 | return; 165 | } 166 | 167 | // Use server-side ping endpoint 168 | const response = await fetch('/api/ping', { 169 | method: 'POST', 170 | headers: { 171 | 'Content-Type': 'application/json', 172 | }, 173 | body: JSON.stringify({ services: servicesToPing }), 174 | }); 175 | 176 | if (response.ok) { 177 | const results = await response.json(); 178 | setStatuses(results); 179 | } else { 180 | console.error('Failed to ping services:', response.statusText); 181 | } 182 | } catch (error) { 183 | console.error('Error pinging services:', error); 184 | } 185 | }; 186 | 187 | if (groups.length > 0) { 188 | pingServices(); 189 | intervalRef.current = setInterval(pingServices, 60000); 190 | } 191 | 192 | return () => { 193 | if (intervalRef.current) clearInterval(intervalRef.current); 194 | }; 195 | }, [groups]); 196 | 197 | // Get theme configuration 198 | const currentTheme = getTheme(mode); 199 | 200 | const themeStyles = { 201 | backgroundColor: currentTheme.backgroundColor, 202 | color: currentTheme.color, 203 | minHeight: '100vh', 204 | backgroundImage: backgroundUrl ? `url(${backgroundUrl})` : currentTheme.backgroundImage ? `url(${currentTheme.backgroundImage})` : undefined, 205 | backgroundSize: 'cover', 206 | backgroundAttachment: 'fixed', 207 | backgroundRepeat: 'no-repeat', 208 | backgroundPosition: 'center', 209 | // Apply custom theme styles 210 | ...(currentTheme.cardStyle && { 211 | fontFamily: currentTheme.cardStyle.fontFamily 212 | }) 213 | }; 214 | 215 | // Add CSS class for special background effects 216 | const themeClass = mode === 'matrix' ? 'matrix-bg' : 217 | mode === 'retro' ? 'retro-bg' : 218 | mode === 'nuclear' ? 'nuclear-bg' : 219 | mode === 'high_contrast_light' ? 'high-contrast-light-bg' : 220 | mode === 'high_contrast_dark' ? 'high-contrast-dark-bg' : ''; 221 | 222 | const filterServices = (services) => 223 | services.filter(service => 224 | service.name.toLowerCase().includes(searchTerm.toLowerCase()) 225 | ); 226 | 227 | return ( 228 |
237 | setDrawerOpen(false)} 240 | pages={pages} 241 | themeStyles={themeStyles} 242 | /> 243 |
244 | setDrawerOpen(true)} 246 | style={{ 247 | position: 'absolute', 248 | left: '20px', 249 | top: '50%', 250 | transform: 'translateY(-50%)', 251 | color: themeStyles.color, 252 | width: iconSize, 253 | height: iconSize, 254 | padding: '8px' 255 | }} 256 | title="Navigation Menu" 257 | > 258 | 262 | 263 |

{currentPage ? currentPage.title : dashboardTitle}

264 | 271 | 280 | 284 | 285 | 286 |
287 | 288 | {/* Alert notification banner */} 289 | {alertSettings.enabled && mode !== "service_mode" && ( 290 |
291 | 292 | Alerts are enabled but will only work when using "Service Mode" theme. 293 | 294 | Change to Service Mode 295 | 296 | 297 |
298 | )} 299 | 300 | {/* DateTime widgets displayed prominently below title */} 301 | {widgets && widgets.length > 0 && ( 302 | 307 | )} 308 | 309 |
310 | setSearchTerm(e.target.value)} 315 | style={{ padding: '8px', width: '300px', borderRadius: '5px', border: '1px solid #ccc', color: themeStyles.color, background: 'transparent' }} 316 | /> 317 | {barIcons.length > 0 && ( 318 |
319 | {barIcons.map((icon, idx) => ( 320 | 339 | ))} 340 |
341 | )} 342 |
343 | 344 | {/* Other widgets displayed smaller */} 345 | {widgets && widgets.length > 0 && ( 346 | 351 | )} 352 | 353 | {groups.map((group, idx) => { 354 | const filtered = filterServices(group.services || []); 355 | if (filtered.length === 0) return null; 356 | return ( 357 |
358 |

{group.title}

359 |
360 | {filtered.map((service, index) => { 361 | const key = `${service.ip}:${service.port}`; 362 | return ( 363 | 373 | ); 374 | })} 375 |
376 |
377 | ); 378 | })} 379 |
390 | Created by SluberskiHomelab on GitHub 391 |
392 |
393 | ); 394 | }; 395 | 396 | const App = () => { 397 | return ( 398 | 399 | 400 | } /> 401 | } /> 402 | } /> 403 | 404 | 405 | ); 406 | }; 407 | 408 | export default App; 409 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DitDashDot has now been moved to forgejo. This repository is archived on Github. Any future development will be done at the below link 2 | 3 | Forgejo Repo: https://git.sluberskihomelab.com/Public/ditdashdot 4 | 5 | Screenshot 2025-08-21 at 10-31-25 Homelab Dashboard 6 | 7 | # DitDashDot 8 | 9 | **DitDashDot** is a modern, feature-rich homelab dashboard that provides a centralized hub for monitoring and accessing your services. Built with React.js and powered by a robust Node.js backend, it offers an intuitive interface for managing complex homelab environments with advanced widgets, multi-page support, and comprehensive customization options. 10 | 11 | *Version 2.3 is out now with enhanced themes, alerts, and widgets! See the [CHANGELOG.md](CHANGELOG.md) for details.* 12 | 13 | *Partially Vibe-Coded* 14 | 15 | ## Features 16 | 17 | ### Core Features 18 | - Simple and intuitive dashboard interface 19 | - Clean, modern design focused on usability 20 | - Real-time service status monitoring 21 | - Fully web-based configuration interface 22 | - PostgreSQL database for reliable configuration storage 23 | 24 | ### Dashboard Features 25 | - Group services into logical categories 26 | - Customizable service cards with icons 27 | - Quick access bar for frequently used links 28 | - Real-time service health status 29 | - Search functionality for quick service access 30 | 31 | ### Configuration Features 32 | - Web-based configuration interface at /config 33 | - Live preview of changes 34 | - Group management 35 | - Service configuration 36 | - Quick access icon management 37 | - Theme customization 38 | 39 | ### Alert Management Features 40 | - **Service Down Notifications**: Automatically detect when services go offline 41 | - **Webhook Integration**: Send alerts to Discord, Slack, or other services via webhooks 42 | - **Custom Down Thresholds**: Configure how long services must be down before alerting 43 | - **Alert Pause System**: Temporarily pause alerts during maintenance (1hr, 4hr, 24hr options) 44 | - **Alert History**: Track all alert events with timestamps and delivery status 45 | - **Per-Service Settings**: Override global alert settings for individual services 46 | - **Service Mode Integration**: Alerts only active when using Service Status theme mode 47 | 48 | ### Enhanced Theme System (v2.3) 49 | - **11 Comprehensive Themes** with advanced visual effects: 50 | - **Professional Themes**: Light Mode, Dark Mode, Transparent Light/Dark 51 | - **Service Status Themes**: Service Mode (original), Service Mode Dark, Service Mode Light 52 | - **Retro/Gaming Themes**: Retro (1990's beige with scanlines), Matrix (animated green rain), Nuclear (radioactive glow) 53 | - **Accessibility Themes**: High Contrast Light, High Contrast Dark (WCAG compliant) 54 | - **Advanced Visual Effects**: CSS animations, background patterns, hover effects 55 | - **Modular Architecture**: Centralized theme configuration for easy customization 56 | - Customizable fonts and sizes 57 | - Custom background support 58 | - Configurable favicon and page titles 59 | 60 | ### Widget System 61 | - **DateTime Widget**: Real-time clock with customizable formatting and timezones 62 | - **Weather Widget**: Current conditions from OpenWeatherMap API with configurable units 63 | - **Sun Position Widget**: Sunrise/sunset times with daylight progress tracking 64 | - **Flexible Configuration**: JSON-based widget settings with live preview 65 | 66 | ## Getting Started 67 | *🚀 Version 2.2 is now available with comprehensive widgets system! * 68 | 69 | *Partially Vibe-Coded* 70 | 71 | ## ✨ Features 72 | 73 | ### 🎛️ Core Dashboard Features 74 | - **Multi-Page Support**: Organize services across multiple pages with seamless navigation 75 | - **Collapsible Navigation Menu**: Hamburger menu with page switching and clean URL routing 76 | - **Real-Time Service Monitoring**: TCP-based health checks with visual status indicators 77 | - **Advanced Search**: Instant filtering across all services and pages 78 | - **Responsive Design**: Optimized for desktop, tablet, and mobile devices 79 | 80 | ### 📊 Comprehensive Widgets System 81 | - **Weather Widget**: Real-time weather conditions with OpenWeatherMap integration 82 | - Current temperature, humidity, wind speed 83 | - Weather icons and detailed forecasts 84 | - Configurable units (metric/imperial) and locations 85 | - **Sun Position Widget**: Solar tracking with sunrise/sunset times 86 | - Daylight duration calculation 87 | - Real-time progress bars showing day/night status 88 | - Configurable latitude/longitude coordinates 89 | - **Date/Time Widget**: Customizable clock and calendar display 90 | - Horizontal layout with time-first prominence 91 | - Configurable timezones, formats, and greeting messages 92 | - Real-time updates with compact 12pt font design 93 | 94 | ### 🛠️ Configuration & Management 95 | - **Intuitive Web Interface**: Complete dashboard configuration at `/config` 96 | - **JSON Configuration Editor**: Advanced widget settings with real-time validation 97 | - **CRUD Operations**: Full Create, Read, Update, Delete for all components 98 | - **Live Preview**: See changes instantly without restarts 99 | - **Import/Export**: Backup and restore configurations easily 100 | 101 | ### 🎨 Appearance & Theming 102 | - **Multiple Theme Modes**: 103 | - Light Mode - Clean and bright interface 104 | - Dark Mode - Easy on the eyes for 24/7 monitoring 105 | - Transparent Light/Dark - Overlay your custom backgrounds 106 | - Service Status Mode - Color-coded based on service health 107 | - **Custom Styling**: Fonts, sizes, colors, and backgrounds 108 | - **Flexible Layout**: Configurable widget positioning and sizing 109 | - **Brand Customization**: Custom favicons, titles, and branding 110 | 111 | ### 🔧 Service Management 112 | - **Flexible Service Configuration**: Optional IP/port fields for diverse service types 113 | - **Group Organization**: Logical categorization with drag-and-drop ordering 114 | - **Icon Support**: Custom icons for services and quick-access toolbar 115 | - **Health Monitoring**: Automated ping checks with customizable intervals 116 | - **Service Cards**: Rich information display with status indicators 117 | 118 | ## 🚀 Getting Started 119 | 120 | ### Prerequisites 121 | 122 | - [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) v2+ 123 | - 512MB+ available RAM 124 | - Modern web browser (Chrome, Firefox, Safari, Edge) 125 | 126 | ### 📦 Quick Deployment 127 | 128 | **\Docker Compose (Recommended)** 129 | 130 | 1. Create your project directory: 131 | ```bash 132 | mkdir ditdashdot && cd ditdashdot 133 | ``` 134 | 135 | 2. Download the production-ready docker-compose.yml: 136 | ```bash 137 | curl -O https://raw.githubusercontent.com/SluberskiHomeLab/ditdashdot/main/docker-compose.yml 138 | ``` 139 | 140 | Or create it manually with the following content: 141 | ```yaml 142 | services: 143 | dashboard: 144 | image: sluberskihomelab/ditdashdot-dashboard:latest 145 | ports: 146 | - "80:80" 147 | depends_on: 148 | - api 149 | - db 150 | restart: always 151 | 152 | api: 153 | image: sluberskihomelab/ditdashdot-api:latest 154 | ports: 155 | - "3001:3001" 156 | environment: 157 | - POSTGRES_USER=ditdashdot 158 | - POSTGRES_PASSWORD=ditdashdot 159 | - POSTGRES_DB=ditdashdot 160 | - POSTGRES_HOST=db 161 | depends_on: 162 | - db 163 | restart: always 164 | 165 | db: 166 | image: postgres:14-alpine 167 | environment: 168 | - POSTGRES_USER=ditdashdot 169 | - POSTGRES_PASSWORD=ditdashdot 170 | - POSTGRES_DB=ditdashdot 171 | volumes: 172 | - postgres_data:/var/lib/postgresql/data 173 | restart: always 174 | 175 | volumes: 176 | postgres_data: 177 | ``` 178 | 179 | 3. Launch your dashboard: 180 | ```bash 181 | docker compose up -d 182 | ``` 183 | 184 | This will start three services: 185 | - Frontend Dashboard (accessible at http://localhost:80) 186 | - Backend API Server 187 | - PostgreSQL Database 188 | 189 | 4. Access your dashboard: 190 | - Main dashboard: http://localhost:80 191 | - Configuration interface: http://localhost:80/config 192 | 193 | ### Docker Hub Images 194 | 195 | DitDashDot is available as pre-built images on Docker Hub: 196 | 197 | - **Dashboard**: `sluberskihomelab/ditdashdot-dashboard:latest` or `sluberskihomelab/ditdashdot-dashboard:2.3` 198 | - **API**: `sluberskihomelab/ditdashdot-api:latest` or `sluberskihomelab/ditdashdot-api:2.3` 199 | 200 | ### Advanced Installation 201 | 202 | For development or custom deployments, you can clone the repository: 203 | 204 | ```bash 205 | git clone https://github.com/SluberskiHomeLab/ditdashdot.git 206 | cd ditdashdot 207 | docker compose up -d 208 | ``` 209 | 210 | ### 🎯 Access Your Dashboard 211 | 212 | After deployment, access your services at: 213 | - **Main Dashboard**: http://localhost (or your server's IP) 214 | - **Configuration Interface**: http://localhost/config 215 | - **API Documentation**: http://localhost:3001/api 216 | 217 | The system automatically creates: 218 | - PostgreSQL database with persistent storage 219 | - Default "Home" page ready for customization 220 | - Sample configuration to get you started 221 | 222 | ### 🔧 First-Time Setup 223 | 224 | 1. **Navigate to Configuration**: Visit `/config` to begin setup 225 | 2. **Add Your Services**: Use the Services tab to add your homelab services 226 | 3. **Configure Widgets**: Set up weather, time, and solar widgets in the Widgets tab 227 | 4. **Customize Appearance**: Choose your theme and styling in General Settings 228 | 5. **Create Pages**: Organize services across multiple pages as needed 229 | 230 | ## ⚙️ Configuration 231 | 232 | DitDashDot provides a powerful, intuitive web-based configuration interface that makes managing complex homelab setups effortless. 233 | 234 | #### You can find Documentation about running and configuring DitDashDot in the Wiki 235 | 236 | #### Widget Management 237 | - **Add/Edit Widgets**: Configure datetime, weather, and sun position widgets 238 | - **JSON Configuration**: Advanced widget settings with real-time validation 239 | - **Display Control**: Enable/disable widgets and set display order 240 | - **Page Assignment**: Assign widgets to specific dashboard pages 241 | 242 | #### Alert Management 243 | - **Enable/Disable Alerts**: Global toggle for the entire alert system 244 | - **Down Threshold**: Configure how many minutes services must be down before triggering alerts (default: 5 minutes) 245 | - **Webhook Configuration**: Set up Discord, Slack, or other webhook URLs for notifications 246 | - **Alert Pausing**: Temporarily pause alerts for maintenance periods (1, 4, or 24 hours) 247 | - **Alert History**: View all past alerts with delivery status and timestamps 248 | - **Per-Service Settings**: Override global settings for individual services 249 | 250 | **Important**: Alerts only function when using any of the Service Status themes (Service Mode, Service Mode Dark, or Service Mode Light). These themes provide visual color-coding for service status (green for up, red for down, gray for unknown) which is required for the alert system to function properly. The muted variants (Dark/Light) provide the same functionality with more subtle colors for better readability. 251 | 252 | For detailed setup instructions, see [ALERTS.md](ALERTS.md). 253 | 254 | ### Theme System (v2.3) 255 | 256 | DitDashDot now features an enhanced theme system with 11 distinct visual themes: 257 | 258 | #### Professional Themes 259 | - **Light Mode**: Clean white background with dark text for professional environments 260 | - **Dark Mode**: Modern dark theme with light text for low-light conditions 261 | - **Transparent Light/Dark**: See-through backgrounds perfect for custom wallpapers 262 | 263 | #### Service Status Themes 264 | - **Service Mode**: Original status-based coloring with bright green/red/gray indicators 265 | - **Service Mode Dark**: Muted dark variant with subtle status colors for better readability 266 | - **Service Mode Light**: Muted light variant with gentle status indication 267 | 268 | #### Retro & Gaming Themes 269 | - **Retro**: 1990's beige aesthetic with vintage scanline effects and diagonal patterns 270 | - **Matrix**: Green-on-black theme with animated matrix rain effect 271 | - **Nuclear**: Dark theme with radioactive yellow/orange glow animations 272 | 273 | #### Accessibility Themes 274 | - **High Contrast Light**: Pure white background with bold 4px black borders and heavy typography 275 | - **High Contrast Dark**: Pure black background with bold 4px white borders for maximum contrast 276 | 277 | All themes feature hardware-accelerated animations, responsive design, and consistent styling across all interface components. 278 | 279 | ### Data Persistence 280 | 281 | ### 🔄 Configuration Best Practices 282 | 283 | - **Regular Backups**: Export configurations before major changes 284 | - **Environment Variables**: Use secrets for API keys in production 285 | - **Testing**: Validate widget configurations in development first 286 | - **Documentation**: Keep notes on custom configurations and API keys 287 | - **Monitoring**: Check widget API quotas and service availability 288 | 289 | ## 🏗️ Architecture & Technologies 290 | 291 | ### **Frontend Stack** 292 | - **React 18**: Modern hooks-based architecture with concurrent features 293 | - **Material-UI v5**: Comprehensive component library with theming 294 | - **React Router v6**: Client-side routing with clean URL patterns 295 | - **Axios**: HTTP client with interceptors and error handling 296 | - **CSS-in-JS**: Dynamic theming and responsive design 297 | 298 | ### **Backend Stack** 299 | - **Node.js 18**: Modern JavaScript runtime with ES modules 300 | - **Express.js**: Lightweight, fast web framework 301 | - **PostgreSQL 14**: Enterprise-grade database with JSONB support 302 | - **RESTful API**: Clean, predictable endpoint design 303 | - **TCP Socket Health Checks**: Reliable service monitoring 304 | 305 | ### **Infrastructure & Deployment** 306 | - **Docker Multi-Stage Builds**: Optimized production images 307 | - **Nginx**: High-performance reverse proxy and static file serving 308 | - **Docker Hub**: Official images with semantic versioning 309 | - **Database Migrations**: Automated schema updates 310 | 311 | ### **Third-Party Integrations** 312 | - **OpenWeatherMap API**: Real-time weather data 313 | - **Sunrise-Sunset API**: Solar position calculations 314 | - **Custom Icon Support**: External image hosting compatibility 315 | 316 | ## 🛠️ Development 317 | 318 | ### Local Development Setup 319 | 320 | ## Technologies Used 321 | 322 | - **Frontend**: 323 | - React.js 18 with modern hooks 324 | - Material-UI (MUI) components 325 | - React Router for navigation 326 | - CSS3 animations and effects 327 | - Modular theme architecture 328 | 329 | - **Backend**: 330 | - Node.js/Express API server 331 | - PostgreSQL 14 database 332 | - RESTful API with webhook integration 333 | - Real-time service monitoring 334 | - Alert management system 335 | 336 | - **Infrastructure**: 337 | - Docker containerization 338 | - Docker Compose orchestration 339 | - Nginx reverse proxy 340 | - Alpine Linux base images 341 | - Docker Hub distribution 342 | 343 | ## Development 344 | 345 | 2. **Development with Docker** (Recommended): 346 | ```bash 347 | docker compose up -d --build 348 | ``` 349 | 350 | 3. **Native development**: 351 | ```bash 352 | # Frontend 353 | npm install 354 | npm start 355 | 356 | # Backend (separate terminal) 357 | cd server 358 | npm install 359 | npm run dev 360 | ``` 361 | 362 | ### 🧪 Testing & Quality 363 | 364 | - **ESLint**: Code quality and consistency 365 | - **Prettier**: Automated code formatting 366 | - **Docker Health Checks**: Container monitoring 367 | - **API Testing**: Endpoint validation and error handling 368 | 369 | ## 🤝 Contributing 370 | 371 | We welcome contributions from the homelab community! Here's how to get involved: 372 | 373 | ### 🐛 **Bug Reports** 374 | - Use GitHub Issues with detailed reproduction steps 375 | - Include environment details (Docker version, OS, browser) 376 | - Attach logs and screenshots when applicable 377 | 378 | ### 💡 **Feature Requests** 379 | - Describe the use case and expected behavior 380 | - Consider backward compatibility 381 | - Propose implementation approach if possible 382 | 383 | ### 🔧 **Pull Requests** 384 | 1. Fork the repository and create a feature branch 385 | 2. Follow existing code style and conventions 386 | 3. Test thoroughly in different environments 387 | 4. Update documentation and changelog 388 | 5. Submit PR with clear description 389 | 390 | ### 📋 **Development Guidelines** 391 | - Keep changes focused and atomic 392 | - Maintain backward compatibility when possible 393 | - Follow semantic versioning for releases 394 | - Write clear commit messages 395 | 396 | ## 📄 License 397 | 398 | This project is licensed under the [MIT License](LICENSE) - see the file for details. 399 | 400 | *Star ⭐ this repository if DitDashDot helps organize your homelab!* 401 | -------------------------------------------------------------------------------- /src/components/ConfigEditor.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Container, 4 | Paper, 5 | Typography, 6 | TextField, 7 | Switch, 8 | FormControlLabel, 9 | Button, 10 | Box, 11 | IconButton, 12 | List, 13 | ListItem, 14 | ListItemText, 15 | ListItemSecondaryAction, 16 | Dialog, 17 | DialogTitle, 18 | DialogContent, 19 | DialogActions, 20 | MenuItem, 21 | Select, 22 | FormControl, 23 | InputLabel, 24 | Grid, 25 | Divider 26 | } from '@mui/material'; 27 | import { Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, DragHandle as DragHandleIcon } from '@mui/icons-material'; 28 | import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; 29 | 30 | const API_BASE_URL = '/api'; 31 | 32 | const ConfigEditor = () => { 33 | const [settings, setSettings] = useState({}); 34 | const [groups, setGroups] = useState([]); 35 | const [services, setServices] = useState([]); 36 | const [icons, setIcons] = useState([]); 37 | const [openDialog, setOpenDialog] = useState(null); 38 | const [editingItem, setEditingItem] = useState(null); 39 | 40 | useEffect(() => { 41 | fetchAllData(); 42 | }, []); 43 | 44 | const fetchAllData = async () => { 45 | try { 46 | const [settingsRes, groupsRes, servicesRes, iconsRes] = await Promise.all([ 47 | fetch(`${API_BASE_URL}/settings`), 48 | fetch(`${API_BASE_URL}/groups`), 49 | fetch(`${API_BASE_URL}/services`), 50 | fetch(`${API_BASE_URL}/icons`) 51 | ]); 52 | 53 | const [settingsData, groupsData, servicesData, iconsData] = await Promise.all([ 54 | settingsRes.json(), 55 | groupsRes.json(), 56 | servicesRes.json(), 57 | iconsRes.json() 58 | ]); 59 | 60 | setSettings(settingsData); 61 | setGroups(groupsData); 62 | setServices(servicesData); 63 | setIcons(iconsData); 64 | } catch (error) { 65 | console.error('Error fetching data:', error); 66 | } 67 | }; 68 | 69 | const handleSettingsChange = async (field, value) => { 70 | const newSettings = { ...settings, [field]: value }; 71 | setSettings(newSettings); 72 | try { 73 | await fetch(`${API_BASE_URL}/settings`, { 74 | method: 'PUT', 75 | headers: { 'Content-Type': 'application/json' }, 76 | body: JSON.stringify(newSettings) 77 | }); 78 | } catch (error) { 79 | console.error('Error updating settings:', error); 80 | } 81 | }; 82 | 83 | const handleGroupSubmit = async (groupData) => { 84 | try { 85 | const method = groupData.id ? 'PUT' : 'POST'; 86 | const url = groupData.id ? `${API_BASE_URL}/groups/${groupData.id}` : `${API_BASE_URL}/groups`; 87 | await fetch(url, { 88 | method, 89 | headers: { 'Content-Type': 'application/json' }, 90 | body: JSON.stringify(groupData) 91 | }); 92 | fetchAllData(); 93 | setOpenDialog(null); 94 | } catch (error) { 95 | console.error('Error saving group:', error); 96 | } 97 | }; 98 | 99 | const handleServiceSubmit = async (serviceData) => { 100 | try { 101 | const method = serviceData.id ? 'PUT' : 'POST'; 102 | const url = serviceData.id ? `${API_BASE_URL}/services/${serviceData.id}` : `${API_BASE_URL}/services`; 103 | await fetch(url, { 104 | method, 105 | headers: { 'Content-Type': 'application/json' }, 106 | body: JSON.stringify(serviceData) 107 | }); 108 | fetchAllData(); 109 | setOpenDialog(null); 110 | } catch (error) { 111 | console.error('Error saving service:', error); 112 | } 113 | }; 114 | 115 | const handleIconSubmit = async (iconData) => { 116 | try { 117 | const method = iconData.id ? 'PUT' : 'POST'; 118 | const url = iconData.id ? `${API_BASE_URL}/icons/${iconData.id}` : `${API_BASE_URL}/icons`; 119 | await fetch(url, { 120 | method, 121 | headers: { 'Content-Type': 'application/json' }, 122 | body: JSON.stringify(iconData) 123 | }); 124 | fetchAllData(); 125 | setOpenDialog(null); 126 | } catch (error) { 127 | console.error('Error saving icon:', error); 128 | } 129 | }; 130 | 131 | const handleDelete = async (type, id) => { 132 | try { 133 | await fetch(`${API_BASE_URL}/${type}/${id}`, { method: 'DELETE' }); 134 | fetchAllData(); 135 | } catch (error) { 136 | console.error('Error deleting item:', error); 137 | } 138 | }; 139 | 140 | const handleDragEnd = async (result) => { 141 | if (!result.destination) return; 142 | 143 | const items = Array.from(result.type === 'group' ? groups : result.type === 'service' ? services : icons); 144 | const [reorderedItem] = items.splice(result.source.index, 1); 145 | items.splice(result.destination.index, 0, reorderedItem); 146 | 147 | // Update display_order for all items 148 | const updatedItems = items.map((item, index) => ({ ...item, display_order: index })); 149 | 150 | if (result.type === 'group') { 151 | setGroups(updatedItems); 152 | } else if (result.type === 'service') { 153 | setServices(updatedItems); 154 | } else { 155 | setIcons(updatedItems); 156 | } 157 | 158 | // Update the database 159 | try { 160 | await Promise.all(updatedItems.map(item => 161 | fetch(`${API_BASE_URL}/${result.type}s/${item.id}`, { 162 | method: 'PUT', 163 | headers: { 'Content-Type': 'application/json' }, 164 | body: JSON.stringify(item) 165 | }) 166 | )); 167 | } catch (error) { 168 | console.error('Error updating order:', error); 169 | fetchAllData(); // Reload the original order if there's an error 170 | } 171 | }; 172 | 173 | return ( 174 | 175 | 176 | 177 | Dashboard Configuration 178 | 179 | 180 | {/* Global Settings */} 181 | 182 | 183 | Global Settings 184 | 185 | 186 | 187 | handleSettingsChange('title', e.target.value)} 192 | /> 193 | 194 | 195 | handleSettingsChange('tab_title', e.target.value)} 200 | /> 201 | 202 | 203 | handleSettingsChange('favicon_url', e.target.value)} 208 | /> 209 | 210 | 211 | handleSettingsChange('background_url', e.target.value)} 216 | /> 217 | 218 | 219 | 220 | Mode 221 | 232 | 233 | 234 | 235 | handleSettingsChange('font_family', e.target.value)} 240 | /> 241 | 242 | 243 | handleSettingsChange('font_size', e.target.value)} 248 | /> 249 | 250 | 251 | 252 | 253 | {/* Groups */} 254 | 255 | 256 | Groups 257 | 268 | 269 | 270 | 271 | {(provided) => ( 272 | 273 | {groups.map((group, index) => ( 274 | 275 | {(provided) => ( 276 | 281 | 282 | 283 | 284 | { 287 | setEditingItem(group); 288 | setOpenDialog('group'); 289 | }} 290 | > 291 | 292 | 293 | handleDelete('groups', group.id)} 296 | > 297 | 298 | 299 | 300 | 301 | )} 302 | 303 | ))} 304 | {provided.placeholder} 305 | 306 | )} 307 | 308 | 309 | 310 | 311 | {/* Services */} 312 | 313 | 314 | Services 315 | 326 | 327 | 328 | 329 | {(provided) => ( 330 | 331 | {services.map((service, index) => ( 332 | 333 | {(provided) => ( 334 | 339 | 340 | g.id === service.group_id)?.title || 'None'}`} 343 | /> 344 | 345 | { 348 | setEditingItem(service); 349 | setOpenDialog('service'); 350 | }} 351 | > 352 | 353 | 354 | handleDelete('services', service.id)} 357 | > 358 | 359 | 360 | 361 | 362 | )} 363 | 364 | ))} 365 | {provided.placeholder} 366 | 367 | )} 368 | 369 | 370 | 371 | 372 | {/* Icons */} 373 | 374 | 375 | Icons 376 | 387 | 388 | 389 | 390 | {(provided) => ( 391 | 392 | {icons.map((icon, index) => ( 393 | 394 | {(provided) => ( 395 | 400 | 401 | 402 | 403 | { 406 | setEditingItem(icon); 407 | setOpenDialog('icon'); 408 | }} 409 | > 410 | 411 | 412 | handleDelete('icons', icon.id)} 415 | > 416 | 417 | 418 | 419 | 420 | )} 421 | 422 | ))} 423 | {provided.placeholder} 424 | 425 | )} 426 | 427 | 428 | 429 | 430 | {/* Dialogs */} 431 | setOpenDialog(null)}> 432 | {editingItem ? 'Edit Group' : 'Add Group'} 433 | 434 | setEditingItem({ ...editingItem, title: e.target.value })} 441 | /> 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | setOpenDialog(null)}> 450 | {editingItem ? 'Edit Service' : 'Add Service'} 451 | 452 | 453 | 454 | 455 | Group 456 | 467 | 468 | 469 | 470 | setEditingItem({ ...editingItem, name: e.target.value })} 475 | /> 476 | 477 | 478 | setEditingItem({ ...editingItem, icon_url: e.target.value })} 483 | /> 484 | 485 | 486 | setEditingItem({ ...editingItem, ip: e.target.value })} 491 | /> 492 | 493 | 494 | setEditingItem({ ...editingItem, port: parseInt(e.target.value) })} 500 | /> 501 | 502 | 503 | setEditingItem({ ...editingItem, url: e.target.value })} 508 | /> 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | setOpenDialog(null)}> 519 | {editingItem ? 'Edit Icon' : 'Add Icon'} 520 | 521 | 522 | 523 | setEditingItem({ ...editingItem, icon_url: e.target.value })} 528 | /> 529 | 530 | 531 | setEditingItem({ ...editingItem, link: e.target.value })} 536 | /> 537 | 538 | 539 | setEditingItem({ ...editingItem, alt: e.target.value })} 544 | /> 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | ); 556 | }; 557 | 558 | export default ConfigEditor; -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cors = require('cors'); 3 | const { Pool } = require('pg'); 4 | 5 | const app = express(); 6 | const port = process.env.PORT || 3001; 7 | 8 | // Database connection 9 | const pool = new Pool({ 10 | user: process.env.POSTGRES_USER, 11 | host: process.env.POSTGRES_HOST, 12 | database: process.env.POSTGRES_DB, 13 | password: process.env.POSTGRES_PASSWORD, 14 | port: 5432, 15 | }); 16 | 17 | app.use(cors()); 18 | app.use(express.json()); 19 | 20 | // Connect to the database and log success 21 | pool.connect() 22 | .then(() => { 23 | console.log('Connected to PostgreSQL database'); 24 | }) 25 | .catch(err => { 26 | console.error('Error connecting to the database:', err); 27 | }); 28 | 29 | // API Routes 30 | 31 | // Health check route 32 | app.get('/api/health', (req, res) => { 33 | res.json({ status: 'healthy' }); 34 | }); 35 | 36 | // Settings routes 37 | app.get('/api/settings', async (req, res) => { 38 | try { 39 | const result = await pool.query('SELECT * FROM dashboard_config'); 40 | res.json(result.rows[0] || {}); 41 | } catch (err) { 42 | console.error(err); 43 | res.status(500).json({ error: 'Internal server error' }); 44 | } 45 | }); 46 | 47 | app.put('/api/settings', async (req, res) => { 48 | try { 49 | const settings = req.body; 50 | await pool.query( 51 | 'UPDATE dashboard_config SET title = $1, tab_title = $2, favicon_url = $3, background_url = $4, mode = $5, show_details = $6, font_family = $7, font_size = $8, icon_size = $9', 52 | [settings.title, settings.tab_title, settings.favicon_url, settings.background_url, settings.mode, settings.show_details, settings.font_family, settings.font_size, settings.icon_size] 53 | ); 54 | res.json({ message: 'Settings updated successfully' }); 55 | } catch (err) { 56 | console.error(err); 57 | res.status(500).json({ error: 'Internal server error' }); 58 | } 59 | }); 60 | 61 | // Groups routes 62 | app.get('/api/groups', async (req, res) => { 63 | try { 64 | const result = await pool.query('SELECT * FROM groups ORDER BY display_order'); 65 | res.json(result.rows); 66 | } catch (err) { 67 | console.error(err); 68 | res.status(500).json({ error: 'Internal server error' }); 69 | } 70 | }); 71 | 72 | app.post('/api/groups', async (req, res) => { 73 | try { 74 | console.log('Received group creation request:', req.body); 75 | const { title, display_order, page_id } = req.body; 76 | 77 | // Validate required fields 78 | if (!title || !page_id) { 79 | console.log('Validation failed:', { title, page_id }); 80 | return res.status(400).json({ error: 'Title and Page are required' }); 81 | } 82 | 83 | const orderValue = display_order || 0; 84 | 85 | const result = await pool.query( 86 | 'INSERT INTO groups (title, display_order, page_id) VALUES ($1, $2, $3) RETURNING *', 87 | [title, orderValue, page_id] 88 | ); 89 | console.log('Group created successfully:', result.rows[0]); 90 | res.json(result.rows[0]); 91 | } catch (err) { 92 | console.error('Error creating group:', err); 93 | if (err.code === '23503') { 94 | res.status(400).json({ error: 'Invalid page ID' }); 95 | } else { 96 | res.status(500).json({ error: `Database error: ${err.message}` }); 97 | } 98 | } 99 | }); 100 | 101 | app.put('/api/groups/:id', async (req, res) => { 102 | try { 103 | console.log('Received group update request:', { id: req.params.id, body: req.body }); 104 | const { id } = req.params; 105 | const { title, display_order, page_id } = req.body; 106 | 107 | // Validate required fields 108 | if (!title || !page_id) { 109 | console.log('Validation failed:', { title, page_id }); 110 | return res.status(400).json({ error: 'Title and Page are required' }); 111 | } 112 | 113 | const orderValue = display_order || 0; 114 | 115 | const result = await pool.query( 116 | 'UPDATE groups SET title = $1, display_order = $2, page_id = $3 WHERE id = $4 RETURNING *', 117 | [title, orderValue, page_id, id] 118 | ); 119 | 120 | if (result.rows.length === 0) { 121 | console.log('Group not found:', id); 122 | return res.status(404).json({ error: 'Group not found' }); 123 | } 124 | 125 | console.log('Group updated successfully:', result.rows[0]); 126 | res.json(result.rows[0]); 127 | } catch (err) { 128 | console.error('Error updating group:', err); 129 | if (err.code === '23503') { 130 | res.status(400).json({ error: 'Invalid page ID' }); 131 | } else { 132 | res.status(500).json({ error: `Database error: ${err.message}` }); 133 | } 134 | } 135 | }); 136 | 137 | app.delete('/api/groups/:id', async (req, res) => { 138 | try { 139 | const { id } = req.params; 140 | await pool.query('DELETE FROM groups WHERE id = $1', [id]); 141 | res.json({ message: 'Group deleted successfully' }); 142 | } catch (err) { 143 | console.error(err); 144 | res.status(500).json({ error: 'Internal server error' }); 145 | } 146 | }); 147 | 148 | // Services routes 149 | app.get('/api/services', async (req, res) => { 150 | try { 151 | const result = await pool.query('SELECT * FROM services ORDER BY display_order'); 152 | res.json(result.rows); 153 | } catch (err) { 154 | console.error(err); 155 | res.status(500).json({ error: 'Internal server error' }); 156 | } 157 | }); 158 | 159 | app.post('/api/services', async (req, res) => { 160 | try { 161 | console.log('Received service creation request:', req.body); 162 | const { name, url, icon_url, group_id, display_order, ip, port } = req.body; 163 | 164 | // Validate required fields 165 | if (!name || !url || !group_id) { 166 | console.log('Validation failed:', { name, url, group_id }); 167 | return res.status(400).json({ error: 'Name, URL, and Group are required' }); 168 | } 169 | 170 | // Ensure display_order is a number 171 | const orderValue = display_order || 0; 172 | 173 | const result = await pool.query( 174 | 'INSERT INTO services (name, url, icon_url, group_id, display_order, ip, port) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *', 175 | [name, url, icon_url || null, group_id, orderValue, ip || null, port ? Number(port) : null] 176 | ); 177 | console.log('Service created successfully:', result.rows[0]); 178 | res.json(result.rows[0]); 179 | } catch (err) { 180 | console.error('Error creating service:', err); 181 | if (err.code === '23503') { 182 | res.status(400).json({ error: 'Invalid group ID' }); 183 | } else { 184 | res.status(500).json({ error: `Database error: ${err.message}` }); 185 | } 186 | } 187 | }); 188 | 189 | app.put('/api/services/:id', async (req, res) => { 190 | try { 191 | console.log('Received service update request:', { id: req.params.id, body: req.body }); 192 | const { id } = req.params; 193 | const { name, url, icon_url, group_id, display_order, ip, port } = req.body; 194 | 195 | // Validate required fields 196 | if (!name || !url || !group_id) { 197 | console.log('Validation failed:', { name, url, group_id }); 198 | return res.status(400).json({ error: 'Name, URL, and Group are required' }); 199 | } 200 | 201 | // Ensure display_order is a number 202 | const orderValue = display_order || 0; 203 | 204 | const result = await pool.query( 205 | 'UPDATE services SET name = $1, url = $2, icon_url = $3, group_id = $4, display_order = $5, ip = $6, port = $7 WHERE id = $8 RETURNING *', 206 | [name, url, icon_url || null, group_id, orderValue, ip || null, port ? Number(port) : null, id] 207 | ); 208 | 209 | if (result.rows.length === 0) { 210 | console.log('Service not found:', id); 211 | return res.status(404).json({ error: 'Service not found' }); 212 | } 213 | 214 | console.log('Service updated successfully:', result.rows[0]); 215 | res.json(result.rows[0]); 216 | } catch (err) { 217 | console.error('Error updating service:', err); 218 | if (err.code === '23503') { 219 | res.status(400).json({ error: 'Invalid group ID' }); 220 | } else { 221 | res.status(500).json({ error: `Database error: ${err.message}` }); 222 | } 223 | } 224 | }); 225 | 226 | app.delete('/api/services/:id', async (req, res) => { 227 | try { 228 | const { id } = req.params; 229 | await pool.query('DELETE FROM services WHERE id = $1', [id]); 230 | res.json({ message: 'Service deleted successfully' }); 231 | } catch (err) { 232 | console.error(err); 233 | res.status(500).json({ error: 'Internal server error' }); 234 | } 235 | }); 236 | 237 | // Icons routes 238 | app.get('/api/icons', async (req, res) => { 239 | try { 240 | const result = await pool.query('SELECT * FROM bar_icons ORDER BY display_order'); 241 | // Transform the response to match frontend field names 242 | const transformedRows = result.rows.map(row => ({ 243 | id: row.id, 244 | iconUrl: row.icon_url, 245 | link: row.link, 246 | alt: row.alt, 247 | display_order: row.display_order 248 | })); 249 | res.json(transformedRows); 250 | } catch (err) { 251 | console.error(err); 252 | res.status(500).json({ error: 'Internal server error' }); 253 | } 254 | }); 255 | 256 | app.post('/api/icons', async (req, res) => { 257 | try { 258 | const { alt, link, iconUrl, display_order } = req.body; 259 | const result = await pool.query( 260 | 'INSERT INTO bar_icons (alt, link, icon_url, display_order) VALUES ($1, $2, $3, $4) RETURNING *', 261 | [alt, link, iconUrl, display_order] 262 | ); 263 | // Transform the response to match frontend field names 264 | const transformedRow = { 265 | id: result.rows[0].id, 266 | iconUrl: result.rows[0].icon_url, 267 | link: result.rows[0].link, 268 | alt: result.rows[0].alt, 269 | display_order: result.rows[0].display_order 270 | }; 271 | res.json(transformedRow); 272 | } catch (err) { 273 | console.error(err); 274 | res.status(500).json({ error: 'Internal server error' }); 275 | } 276 | }); 277 | 278 | app.put('/api/icons/:id', async (req, res) => { 279 | try { 280 | const { id } = req.params; 281 | const { alt, link, iconUrl, display_order } = req.body; 282 | const result = await pool.query( 283 | 'UPDATE bar_icons SET alt = $1, link = $2, icon_url = $3, display_order = $4 WHERE id = $5 RETURNING *', 284 | [alt, link, iconUrl, display_order, id] 285 | ); 286 | // Transform the response to match frontend field names 287 | const transformedRow = { 288 | id: result.rows[0].id, 289 | iconUrl: result.rows[0].icon_url, 290 | link: result.rows[0].link, 291 | alt: result.rows[0].alt, 292 | display_order: result.rows[0].display_order 293 | }; 294 | res.json(transformedRow); 295 | } catch (err) { 296 | console.error(err); 297 | res.status(500).json({ error: 'Internal server error' }); 298 | } 299 | }); 300 | 301 | app.delete('/api/icons/:id', async (req, res) => { 302 | try { 303 | const { id } = req.params; 304 | await pool.query('DELETE FROM bar_icons WHERE id = $1', [id]); 305 | res.json({ message: 'Icon deleted successfully' }); 306 | } catch (err) { 307 | console.error(err); 308 | res.status(500).json({ error: 'Internal server error' }); 309 | } 310 | }); 311 | 312 | // Pages routes 313 | app.get('/api/pages', async (req, res) => { 314 | try { 315 | const result = await pool.query('SELECT * FROM pages ORDER BY display_order'); 316 | res.json(result.rows); 317 | } catch (err) { 318 | console.error('Error fetching pages:', err); 319 | res.status(500).json({ error: 'Internal server error' }); 320 | } 321 | }); 322 | 323 | app.post('/api/pages', async (req, res) => { 324 | try { 325 | console.log('Received page creation request:', req.body); 326 | const { title, display_order } = req.body; 327 | 328 | if (!title) { 329 | console.log('Validation failed: title is required'); 330 | return res.status(400).json({ error: 'Title is required' }); 331 | } 332 | 333 | const orderValue = display_order || 0; 334 | 335 | const result = await pool.query( 336 | 'INSERT INTO pages (title, display_order) VALUES ($1, $2) RETURNING *', 337 | [title, orderValue] 338 | ); 339 | console.log('Page created successfully:', result.rows[0]); 340 | res.json(result.rows[0]); 341 | } catch (err) { 342 | console.error('Error creating page:', err); 343 | res.status(500).json({ error: `Database error: ${err.message}` }); 344 | } 345 | }); 346 | 347 | app.put('/api/pages/:id', async (req, res) => { 348 | try { 349 | console.log('Received page update request:', { id: req.params.id, body: req.body }); 350 | const { id } = req.params; 351 | const { title, display_order } = req.body; 352 | 353 | if (!title) { 354 | console.log('Validation failed: title is required'); 355 | return res.status(400).json({ error: 'Title is required' }); 356 | } 357 | 358 | const orderValue = display_order || 0; 359 | 360 | const result = await pool.query( 361 | 'UPDATE pages SET title = $1, display_order = $2 WHERE id = $3 RETURNING *', 362 | [title, orderValue, id] 363 | ); 364 | 365 | if (result.rows.length === 0) { 366 | console.log('Page not found:', id); 367 | return res.status(404).json({ error: 'Page not found' }); 368 | } 369 | 370 | console.log('Page updated successfully:', result.rows[0]); 371 | res.json(result.rows[0]); 372 | } catch (err) { 373 | console.error('Error updating page:', err); 374 | res.status(500).json({ error: `Database error: ${err.message}` }); 375 | } 376 | }); 377 | 378 | app.delete('/api/pages/:id', async (req, res) => { 379 | try { 380 | console.log('Received page deletion request:', req.params.id); 381 | const { id } = req.params; 382 | 383 | // Check if there are groups associated with this page 384 | const groupCheck = await pool.query('SELECT COUNT(*) FROM groups WHERE page_id = $1', [id]); 385 | if (parseInt(groupCheck.rows[0].count) > 0) { 386 | return res.status(400).json({ error: 'Cannot delete page with associated groups' }); 387 | } 388 | 389 | const result = await pool.query('DELETE FROM pages WHERE id = $1 RETURNING *', [id]); 390 | 391 | if (result.rows.length === 0) { 392 | console.log('Page not found:', id); 393 | return res.status(404).json({ error: 'Page not found' }); 394 | } 395 | 396 | console.log('Page deleted successfully:', result.rows[0]); 397 | res.json({ message: 'Page deleted successfully' }); 398 | } catch (err) { 399 | console.error('Error deleting page:', err); 400 | res.status(500).json({ error: `Database error: ${err.message}` }); 401 | } 402 | }); 403 | 404 | // Enhanced service status check endpoint with alert handling 405 | app.post('/api/ping', async (req, res) => { 406 | try { 407 | const { services } = req.body; 408 | const results = {}; 409 | 410 | // Get alert settings - handle case where table doesn't exist 411 | let alertSettings = {}; 412 | try { 413 | const alertSettingsResult = await pool.query('SELECT * FROM alert_settings ORDER BY id LIMIT 1'); 414 | alertSettings = alertSettingsResult.rows[0] || { enabled: false, down_threshold_minutes: 5 }; 415 | } catch (error) { 416 | // Table doesn't exist yet, use defaults 417 | alertSettings = { enabled: false, down_threshold_minutes: 5 }; 418 | } 419 | 420 | // Check if alerts are paused 421 | const alertsPaused = alertSettings.paused_until && new Date(alertSettings.paused_until) > new Date(); 422 | 423 | // Use Promise.allSettled to ping all services concurrently 424 | const pingPromises = services.map(async (service) => { 425 | if (!service.ip || !service.port) { 426 | return { key: `${service.id}`, status: null, service }; // No IP/port to ping 427 | } 428 | 429 | const key = `${service.ip}:${service.port}`; 430 | const net = require('net'); 431 | 432 | return new Promise((resolve) => { 433 | const socket = new net.Socket(); 434 | const timeout = 3000; 435 | 436 | socket.setTimeout(timeout); 437 | socket.on('connect', () => { 438 | socket.destroy(); 439 | resolve({ key, status: true, service }); 440 | }); 441 | 442 | socket.on('timeout', () => { 443 | socket.destroy(); 444 | resolve({ key, status: false, service }); 445 | }); 446 | 447 | socket.on('error', () => { 448 | socket.destroy(); 449 | resolve({ key, status: false, service }); 450 | }); 451 | 452 | socket.connect(service.port, service.ip); 453 | }); 454 | }); 455 | 456 | const pingResults = await Promise.allSettled(pingPromises); 457 | 458 | // Process results and handle alerts 459 | for (const result of pingResults) { 460 | if (result.status === 'fulfilled' && result.value.key) { 461 | const { key, status, service } = result.value; 462 | results[key] = status; 463 | 464 | // Handle alert logic if alerts are enabled and not paused 465 | if (alertSettings.enabled && !alertsPaused && service.alert_enabled !== false) { 466 | const serviceKey = `service_${service.id}`; 467 | const previousStatus = serviceStatusHistory.get(serviceKey); 468 | const now = new Date(); 469 | 470 | // Initialize or update service status history 471 | if (!previousStatus) { 472 | serviceStatusHistory.set(serviceKey, { 473 | status: status, 474 | lastCheck: now, 475 | downSince: status === false ? now : null 476 | }); 477 | } else { 478 | const wasDown = previousStatus.status === false; 479 | const isNowDown = status === false; 480 | const isNowUp = status === true; 481 | 482 | // Service went down 483 | if (!wasDown && isNowDown) { 484 | serviceStatusHistory.set(serviceKey, { 485 | status: false, 486 | lastCheck: now, 487 | downSince: now 488 | }); 489 | } 490 | // Service came back up 491 | else if (wasDown && isNowUp) { 492 | serviceStatusHistory.set(serviceKey, { 493 | status: true, 494 | lastCheck: now, 495 | downSince: null 496 | }); 497 | 498 | // Send recovery alert immediately 499 | if (alertSettings.webhook_enabled && alertSettings.webhook_url) { 500 | const alertData = { 501 | service_id: service.id, 502 | service_name: service.name, 503 | service_ip: service.ip, 504 | service_port: service.port, 505 | alert_type: 'service_up', 506 | message: `🟢 Service "${service.name}" is back online!` 507 | }; 508 | 509 | const webhookResult = await sendWebhook(alertSettings.webhook_url, alertData); 510 | await logAlert(alertData, webhookResult); 511 | } 512 | } 513 | // Service still down - check if threshold reached 514 | else if (wasDown && isNowDown && previousStatus.downSince) { 515 | const downDuration = (now - previousStatus.downSince) / (1000 * 60); // minutes 516 | const threshold = service.down_threshold_minutes || alertSettings.down_threshold_minutes || 5; 517 | const cooldownKey = `alert_${service.id}`; 518 | 519 | // Check if we haven't sent an alert recently (cooldown) 520 | const lastAlert = alertCooldowns.get(cooldownKey); 521 | const cooldownMinutes = 30; // Don't spam alerts more than every 30 minutes 522 | 523 | if (downDuration >= threshold && 524 | (!lastAlert || (now - lastAlert) / (1000 * 60) >= cooldownMinutes)) { 525 | 526 | if (alertSettings.webhook_enabled && alertSettings.webhook_url) { 527 | const alertData = { 528 | service_id: service.id, 529 | service_name: service.name, 530 | service_ip: service.ip, 531 | service_port: service.port, 532 | alert_type: 'service_down', 533 | message: `🔴 Service "${service.name}" has been down for ${Math.round(downDuration)} minutes!` 534 | }; 535 | 536 | const webhookResult = await sendWebhook(alertSettings.webhook_url, alertData); 537 | await logAlert(alertData, webhookResult); 538 | alertCooldowns.set(cooldownKey, now); 539 | } 540 | } 541 | 542 | // Update last check time 543 | serviceStatusHistory.set(serviceKey, { 544 | ...previousStatus, 545 | lastCheck: now 546 | }); 547 | } 548 | else { 549 | // Update last check time for stable services 550 | serviceStatusHistory.set(serviceKey, { 551 | ...previousStatus, 552 | lastCheck: now 553 | }); 554 | } 555 | } 556 | } 557 | } 558 | } 559 | 560 | res.json(results); 561 | } catch (err) { 562 | console.error('Error in ping endpoint:', err); 563 | res.status(500).json({ error: 'Internal server error' }); 564 | } 565 | }); 566 | 567 | // Widgets routes 568 | app.get('/api/widgets', async (req, res) => { 569 | try { 570 | const result = await pool.query('SELECT * FROM widgets ORDER BY display_order'); 571 | res.json(result.rows); 572 | } catch (err) { 573 | console.error('Error fetching widgets:', err); 574 | res.status(500).json({ error: 'Internal server error' }); 575 | } 576 | }); 577 | 578 | app.post('/api/widgets', async (req, res) => { 579 | try { 580 | console.log('Received widget creation request:', req.body); 581 | const { type, title, config, page_id, display_order, enabled } = req.body; 582 | 583 | if (!type) { 584 | console.log('Validation failed: type is required'); 585 | return res.status(400).json({ error: 'Widget type is required' }); 586 | } 587 | 588 | const orderValue = display_order || 0; 589 | const enabledValue = enabled !== undefined ? enabled : true; 590 | const configValue = config || {}; 591 | 592 | const result = await pool.query( 593 | 'INSERT INTO widgets (type, title, config, page_id, display_order, enabled) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', 594 | [type, title, JSON.stringify(configValue), page_id, orderValue, enabledValue] 595 | ); 596 | console.log('Widget created successfully:', result.rows[0]); 597 | res.json(result.rows[0]); 598 | } catch (err) { 599 | console.error('Error creating widget:', err); 600 | res.status(500).json({ error: `Database error: ${err.message}` }); 601 | } 602 | }); 603 | 604 | app.put('/api/widgets/:id', async (req, res) => { 605 | try { 606 | console.log('Received widget update request:', { id: req.params.id, body: req.body }); 607 | const { id } = req.params; 608 | const { type, title, config, page_id, display_order, enabled } = req.body; 609 | 610 | if (!type) { 611 | console.log('Validation failed: type is required'); 612 | return res.status(400).json({ error: 'Widget type is required' }); 613 | } 614 | 615 | const orderValue = display_order || 0; 616 | const enabledValue = enabled !== undefined ? enabled : true; 617 | const configValue = config || {}; 618 | 619 | const result = await pool.query( 620 | 'UPDATE widgets SET type = $1, title = $2, config = $3, page_id = $4, display_order = $5, enabled = $6 WHERE id = $7 RETURNING *', 621 | [type, title, JSON.stringify(configValue), page_id, orderValue, enabledValue, id] 622 | ); 623 | 624 | if (result.rows.length === 0) { 625 | console.log('Widget not found:', id); 626 | return res.status(404).json({ error: 'Widget not found' }); 627 | } 628 | 629 | console.log('Widget updated successfully:', result.rows[0]); 630 | res.json(result.rows[0]); 631 | } catch (err) { 632 | console.error('Error updating widget:', err); 633 | res.status(500).json({ error: `Database error: ${err.message}` }); 634 | } 635 | }); 636 | 637 | app.delete('/api/widgets/:id', async (req, res) => { 638 | try { 639 | console.log('Received widget deletion request:', req.params.id); 640 | const { id } = req.params; 641 | 642 | const result = await pool.query('DELETE FROM widgets WHERE id = $1 RETURNING *', [id]); 643 | 644 | if (result.rows.length === 0) { 645 | console.log('Widget not found:', id); 646 | return res.status(404).json({ error: 'Widget not found' }); 647 | } 648 | 649 | console.log('Widget deleted successfully:', result.rows[0]); 650 | res.json({ message: 'Widget deleted successfully' }); 651 | } catch (err) { 652 | console.error('Error deleting widget:', err); 653 | res.status(500).json({ error: `Database error: ${err.message}` }); 654 | } 655 | }); 656 | 657 | // Test webhook endpoint 658 | app.post('/api/test-webhook', async (req, res) => { 659 | try { 660 | const { webhook_url } = req.body; 661 | 662 | if (!webhook_url) { 663 | return res.status(400).json({ error: 'Webhook URL is required' }); 664 | } 665 | 666 | const testAlert = { 667 | service_name: 'Test Service', 668 | service_ip: '192.168.1.100', 669 | service_port: 80, 670 | alert_type: 'service_down', 671 | message: '🔴 This is a test alert from DitDashDot!' 672 | }; 673 | 674 | const result = await sendWebhook(webhook_url, testAlert); 675 | 676 | if (result.success) { 677 | res.json({ success: true, message: 'Test webhook sent successfully!' }); 678 | } else { 679 | res.status(400).json({ success: false, error: result.response }); 680 | } 681 | } catch (err) { 682 | console.error('Error testing webhook:', err); 683 | res.status(500).json({ error: `Error testing webhook: ${err.message}` }); 684 | } 685 | }); 686 | 687 | // Alert Settings routes 688 | app.get('/api/alert-settings', async (req, res) => { 689 | try { 690 | // Ensure table exists 691 | await pool.query(` 692 | CREATE TABLE IF NOT EXISTS alert_settings ( 693 | id SERIAL PRIMARY KEY, 694 | enabled BOOLEAN DEFAULT true, 695 | webhook_url TEXT, 696 | webhook_enabled BOOLEAN DEFAULT false, 697 | down_threshold_minutes INTEGER DEFAULT 5, 698 | paused_until TIMESTAMP NULL, 699 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 700 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 701 | ) 702 | `); 703 | 704 | const result = await pool.query('SELECT * FROM alert_settings ORDER BY id LIMIT 1'); 705 | 706 | // Return default values if no settings exist 707 | if (result.rows.length === 0) { 708 | res.json({ 709 | enabled: true, 710 | webhook_url: '', 711 | webhook_enabled: false, 712 | down_threshold_minutes: 5, 713 | paused_until: null 714 | }); 715 | } else { 716 | res.json(result.rows[0]); 717 | } 718 | } catch (err) { 719 | console.error('Error fetching alert settings:', err); 720 | res.status(500).json({ error: 'Internal server error' }); 721 | } 722 | }); 723 | 724 | app.put('/api/alert-settings', async (req, res) => { 725 | try { 726 | console.log('Received alert settings update request:', req.body); 727 | const { enabled, webhook_url, webhook_enabled, down_threshold_minutes, paused_until } = req.body; 728 | 729 | // Ensure table exists and create default record if needed 730 | await pool.query(` 731 | CREATE TABLE IF NOT EXISTS alert_settings ( 732 | id SERIAL PRIMARY KEY, 733 | enabled BOOLEAN DEFAULT true, 734 | webhook_url TEXT, 735 | webhook_enabled BOOLEAN DEFAULT false, 736 | down_threshold_minutes INTEGER DEFAULT 5, 737 | paused_until TIMESTAMP NULL, 738 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 739 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 740 | ) 741 | `); 742 | 743 | // Check if any settings exist 744 | const existingResult = await pool.query('SELECT id FROM alert_settings ORDER BY id LIMIT 1'); 745 | 746 | let result; 747 | if (existingResult.rows.length > 0) { 748 | // Update existing record 749 | result = await pool.query(` 750 | UPDATE alert_settings SET 751 | enabled = $1, 752 | webhook_url = $2, 753 | webhook_enabled = $3, 754 | down_threshold_minutes = $4, 755 | paused_until = $5, 756 | updated_at = CURRENT_TIMESTAMP 757 | WHERE id = $6 758 | RETURNING *`, 759 | [enabled, webhook_url, webhook_enabled, down_threshold_minutes, paused_until, existingResult.rows[0].id] 760 | ); 761 | } else { 762 | // Insert new record 763 | result = await pool.query(` 764 | INSERT INTO alert_settings (enabled, webhook_url, webhook_enabled, down_threshold_minutes, paused_until) 765 | VALUES ($1, $2, $3, $4, $5) 766 | RETURNING *`, 767 | [enabled, webhook_url, webhook_enabled, down_threshold_minutes, paused_until] 768 | ); 769 | } 770 | 771 | console.log('Alert settings updated successfully:', result.rows[0]); 772 | res.json(result.rows[0]); 773 | } catch (err) { 774 | console.error('Error updating alert settings:', err); 775 | res.status(500).json({ error: `Database error: ${err.message}` }); 776 | } 777 | }); 778 | 779 | // Alert History routes 780 | app.get('/api/alert-history', async (req, res) => { 781 | try { 782 | // Ensure alert_history table exists 783 | await pool.query(` 784 | CREATE TABLE IF NOT EXISTS alert_history ( 785 | id SERIAL PRIMARY KEY, 786 | service_id INTEGER, 787 | service_name TEXT NOT NULL, 788 | service_ip TEXT, 789 | service_port INTEGER, 790 | alert_type VARCHAR(50) NOT NULL, 791 | message TEXT, 792 | webhook_sent BOOLEAN DEFAULT false, 793 | webhook_response TEXT, 794 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 795 | ) 796 | `); 797 | 798 | const limit = req.query.limit || 50; 799 | const result = await pool.query( 800 | 'SELECT * FROM alert_history ORDER BY created_at DESC LIMIT $1', 801 | [limit] 802 | ); 803 | res.json(result.rows); 804 | } catch (err) { 805 | console.error('Error fetching alert history:', err); 806 | res.status(500).json({ error: 'Internal server error' }); 807 | } 808 | }); 809 | 810 | app.delete('/api/alert-history', async (req, res) => { 811 | try { 812 | // Ensure table exists before trying to clear it 813 | await pool.query(` 814 | CREATE TABLE IF NOT EXISTS alert_history ( 815 | id SERIAL PRIMARY KEY, 816 | service_id INTEGER, 817 | service_name TEXT NOT NULL, 818 | service_ip TEXT, 819 | service_port INTEGER, 820 | alert_type VARCHAR(50) NOT NULL, 821 | message TEXT, 822 | webhook_sent BOOLEAN DEFAULT false, 823 | webhook_response TEXT, 824 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 825 | ) 826 | `); 827 | 828 | await pool.query('DELETE FROM alert_history'); 829 | console.log('Alert history cleared'); 830 | res.json({ message: 'Alert history cleared successfully' }); 831 | } catch (err) { 832 | console.error('Error clearing alert history:', err); 833 | res.status(500).json({ error: `Database error: ${err.message}` }); 834 | } 835 | }); 836 | 837 | // Enhanced service status check with alert handling 838 | let serviceStatusHistory = new Map(); // Track service status over time 839 | let alertCooldowns = new Map(); // Prevent spam alerts 840 | 841 | const sendWebhook = async (webhookUrl, alertData) => { 842 | try { 843 | const response = await fetch(webhookUrl, { 844 | method: 'POST', 845 | headers: { 846 | 'Content-Type': 'application/json', 847 | }, 848 | body: JSON.stringify({ 849 | username: 'DitDashDot', 850 | content: alertData.message, 851 | embeds: [{ 852 | title: `Service Alert: ${alertData.service_name}`, 853 | description: alertData.message, 854 | color: alertData.alert_type === 'service_down' ? 15158332 : 3066993, // Red for down, green for up 855 | fields: [ 856 | { 857 | name: 'Service', 858 | value: alertData.service_name, 859 | inline: true 860 | }, 861 | { 862 | name: 'Status', 863 | value: alertData.alert_type === 'service_down' ? '🔴 Down' : '🟢 Up', 864 | inline: true 865 | }, 866 | { 867 | name: 'Location', 868 | value: alertData.service_ip && alertData.service_port ? 869 | `${alertData.service_ip}:${alertData.service_port}` : 870 | 'N/A', 871 | inline: true 872 | } 873 | ], 874 | timestamp: new Date().toISOString() 875 | }] 876 | }) 877 | }); 878 | 879 | return { 880 | success: response.ok, 881 | status: response.status, 882 | response: response.ok ? await response.text() : `Error: ${response.statusText}` 883 | }; 884 | } catch (error) { 885 | console.error('Webhook send error:', error); 886 | return { 887 | success: false, 888 | response: `Error: ${error.message}` 889 | }; 890 | } 891 | }; 892 | 893 | const logAlert = async (alertData, webhookResult = null) => { 894 | try { 895 | // Ensure alert_history table exists 896 | await pool.query(` 897 | CREATE TABLE IF NOT EXISTS alert_history ( 898 | id SERIAL PRIMARY KEY, 899 | service_id INTEGER, 900 | service_name TEXT NOT NULL, 901 | service_ip TEXT, 902 | service_port INTEGER, 903 | alert_type VARCHAR(50) NOT NULL, 904 | message TEXT, 905 | webhook_sent BOOLEAN DEFAULT false, 906 | webhook_response TEXT, 907 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 908 | ) 909 | `); 910 | 911 | await pool.query(` 912 | INSERT INTO alert_history 913 | (service_id, service_name, service_ip, service_port, alert_type, message, webhook_sent, webhook_response) 914 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 915 | `, [ 916 | alertData.service_id, 917 | alertData.service_name, 918 | alertData.service_ip, 919 | alertData.service_port, 920 | alertData.alert_type, 921 | alertData.message, 922 | webhookResult?.success || false, 923 | webhookResult?.response || null 924 | ]); 925 | } catch (error) { 926 | console.error('Error logging alert:', error); 927 | } 928 | }; 929 | 930 | // Start the server 931 | app.listen(port, () => { 932 | console.log(`Server running on port ${port}`); 933 | }); 934 | -------------------------------------------------------------------------------- /src/components/config/ConfigurationPage.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Container, 4 | Paper, 5 | Typography, 6 | TextField, 7 | Button, 8 | Box, 9 | Switch, 10 | FormControlLabel, 11 | MenuItem, 12 | Snackbar, 13 | Alert, 14 | Tabs, 15 | Tab, 16 | List, 17 | ListItem, 18 | ListItemText, 19 | ListItemSecondaryAction, 20 | IconButton, 21 | Dialog, 22 | DialogTitle, 23 | DialogContent, 24 | DialogActions, 25 | Divider 26 | } from '@mui/material'; 27 | import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon, Home as HomeIcon } from '@mui/icons-material'; 28 | import { Link } from 'react-router-dom'; 29 | import axios from 'axios'; 30 | import { THEMES } from '../../themes/themeConfig'; 31 | 32 | const API_URL = '/api'; // Using relative URL 33 | 34 | const TabPanel = ({ children, value, index }) => { 35 | return ( 36 | 46 | ); 47 | }; 48 | 49 | const ConfigurationPage = () => { 50 | const [tabValue, setTabValue] = useState(0); 51 | const [settings, setSettings] = useState({ 52 | title: '', 53 | tab_title: '', 54 | favicon_url: '', 55 | mode: 'light_mode', 56 | show_details: true, 57 | background_url: '', 58 | font_family: 'Arial, sans-serif', 59 | font_size: '14px', 60 | icon_size: '32px' 61 | }); 62 | const [pages, setPages] = useState([]); 63 | const [groups, setGroups] = useState([]); 64 | const [services, setServices] = useState([]); 65 | const [widgets, setWidgets] = useState([]); 66 | const [icons, setIcons] = useState([]); 67 | const [alertSettings, setAlertSettings] = useState({ 68 | enabled: true, 69 | webhook_url: '', 70 | webhook_enabled: false, 71 | down_threshold_minutes: 5, 72 | paused_until: null 73 | }); 74 | const [alertHistory, setAlertHistory] = useState([]); 75 | const [alert, setAlert] = useState({ open: false, message: '', severity: 'success' }); 76 | const [dialogOpen, setDialogOpen] = useState(false); 77 | const [dialogType, setDialogType] = useState(''); 78 | const [editingItem, setEditingItem] = useState(null); 79 | 80 | useEffect(() => { 81 | fetchData(); 82 | }, []); 83 | 84 | const fetchData = async () => { 85 | try { 86 | const [settingsRes, groupsRes, servicesRes, iconsRes, pagesRes, widgetsRes, alertSettingsRes, alertHistoryRes] = await Promise.all([ 87 | axios.get(`${API_URL}/settings`), 88 | axios.get(`${API_URL}/groups`), 89 | axios.get(`${API_URL}/services`), 90 | axios.get(`${API_URL}/icons`), 91 | axios.get(`${API_URL}/pages`), 92 | axios.get(`${API_URL}/widgets`), 93 | axios.get(`${API_URL}/alert-settings`).catch(() => ({ data: {} })), 94 | axios.get(`${API_URL}/alert-history?limit=100`).catch(() => ({ data: [] })) 95 | ]); 96 | 97 | setSettings(settingsRes.data || {}); 98 | setPages(pagesRes.data || []); 99 | setGroups(groupsRes.data || []); 100 | setServices(servicesRes.data || []); 101 | setWidgets(widgetsRes.data || []); 102 | setIcons(iconsRes.data || []); 103 | setAlertSettings(alertSettingsRes.data || { 104 | enabled: true, 105 | webhook_url: '', 106 | webhook_enabled: false, 107 | down_threshold_minutes: 5, 108 | paused_until: null 109 | }); 110 | setAlertHistory(alertHistoryRes.data || []); 111 | } catch (error) { 112 | console.error('Error fetching data:', error); 113 | setAlert({ 114 | open: true, 115 | message: 'Error loading configuration', 116 | severity: 'error' 117 | }); 118 | } 119 | }; 120 | 121 | const handleSubmit = async (e) => { 122 | e.preventDefault(); 123 | try { 124 | await axios.put(`${API_URL}/settings`, settings); 125 | setAlert({ 126 | open: true, 127 | message: 'Settings saved successfully', 128 | severity: 'success' 129 | }); 130 | } catch (error) { 131 | console.error('Error saving settings:', error); 132 | setAlert({ 133 | open: true, 134 | message: 'Error saving configuration', 135 | severity: 'error' 136 | }); 137 | } 138 | }; 139 | 140 | const handleChange = (e) => { 141 | const { name, value } = e.target; 142 | setSettings(prev => ({ 143 | ...prev, 144 | [name]: value 145 | })); 146 | }; 147 | 148 | const handleSwitchChange = (e) => { 149 | const { name, checked } = e.target; 150 | setSettings(prev => ({ 151 | ...prev, 152 | [name]: checked 153 | })); 154 | }; 155 | 156 | const handleTabChange = (event, newValue) => { 157 | setTabValue(newValue); 158 | }; 159 | 160 | const handleAdd = (type) => { 161 | setDialogType(type); 162 | const newItem = { display_order: 0 }; 163 | 164 | // Set default page_id for groups and widgets if pages exist 165 | if ((type === 'groups' || type === 'widgets') && pages.length > 0) { 166 | newItem.page_id = pages[0].id; 167 | } 168 | 169 | // Set default values for widgets 170 | if (type === 'widgets') { 171 | newItem.type = 'datetime'; 172 | newItem.enabled = true; 173 | newItem.config = {}; 174 | } 175 | 176 | setEditingItem(newItem); 177 | setDialogOpen(true); 178 | }; 179 | 180 | const handleEdit = (type, item) => { 181 | setDialogType(type); 182 | // Clear any temporary configString when editing 183 | const cleanItem = { ...item }; 184 | if ('configString' in cleanItem) { 185 | delete cleanItem.configString; 186 | } 187 | setEditingItem(cleanItem); 188 | setDialogOpen(true); 189 | }; 190 | 191 | const handleDelete = async (type, id) => { 192 | try { 193 | await axios.delete(`${API_URL}/${type}/${id}`); 194 | fetchData(); 195 | setAlert({ 196 | open: true, 197 | message: 'Item deleted successfully', 198 | severity: 'success' 199 | }); 200 | } catch (error) { 201 | console.error('Error deleting item:', error); 202 | setAlert({ 203 | open: true, 204 | message: 'Error deleting item', 205 | severity: 'error' 206 | }); 207 | } 208 | }; 209 | 210 | const handleDialogSave = async () => { 211 | try { 212 | // Validate required fields 213 | if (dialogType === 'groups' && (!editingItem.title || !editingItem.page_id)) { 214 | setAlert({ 215 | open: true, 216 | message: 'Group name and page are required', 217 | severity: 'error' 218 | }); 219 | return; 220 | } 221 | 222 | if (dialogType === 'pages' && !editingItem.title) { 223 | setAlert({ 224 | open: true, 225 | message: 'Page title is required', 226 | severity: 'error' 227 | }); 228 | return; 229 | } 230 | 231 | if (dialogType === 'widgets' && (!editingItem.type || !editingItem.page_id)) { 232 | setAlert({ 233 | open: true, 234 | message: 'Widget type and page are required', 235 | severity: 'error' 236 | }); 237 | return; 238 | } 239 | 240 | if (dialogType === 'widgets' && editingItem.configString !== undefined) { 241 | setAlert({ 242 | open: true, 243 | message: 'Widget configuration contains invalid JSON. Please fix the JSON syntax.', 244 | severity: 'error' 245 | }); 246 | return; 247 | } 248 | 249 | // Log the data being sent 250 | console.log('Saving item:', { 251 | type: dialogType, 252 | data: editingItem 253 | }); 254 | 255 | const endpoint = `${API_URL}/${dialogType}`; 256 | const method = editingItem.id ? 'put' : 'post'; 257 | const url = editingItem.id ? `${endpoint}/${editingItem.id}` : endpoint; 258 | 259 | const response = await axios[method](url, editingItem); 260 | console.log('Server response:', response.data); 261 | 262 | setDialogOpen(false); 263 | fetchData(); 264 | setAlert({ 265 | open: true, 266 | message: 'Item saved successfully', 267 | severity: 'success' 268 | }); 269 | } catch (error) { 270 | console.error('Error saving item:', error); 271 | console.error('Error details:', error.response?.data || error.message); 272 | setAlert({ 273 | open: true, 274 | message: error.response?.data?.error || 'Error saving item', 275 | severity: 'error' 276 | }); 277 | } 278 | }; 279 | 280 | // Alert management functions 281 | const handleAlertSettingsChange = (field, value) => { 282 | setAlertSettings(prev => ({ 283 | ...prev, 284 | [field]: value 285 | })); 286 | }; 287 | 288 | const handleAlertSettingsSave = async () => { 289 | try { 290 | console.log('Saving alert settings:', alertSettings); 291 | const response = await axios.put(`${API_URL}/alert-settings`, alertSettings); 292 | console.log('Alert settings save response:', response.data); 293 | 294 | // Update local state with saved data 295 | setAlertSettings(response.data); 296 | 297 | setAlert({ 298 | open: true, 299 | message: 'Alert settings saved successfully', 300 | severity: 'success' 301 | }); 302 | } catch (error) { 303 | console.error('Error saving alert settings:', error); 304 | console.error('Error details:', error.response?.data); 305 | 306 | const errorMessage = error.response?.data?.error || 307 | error.response?.data?.message || 308 | 'Error saving alert settings - please check server logs'; 309 | 310 | setAlert({ 311 | open: true, 312 | message: errorMessage, 313 | severity: 'error' 314 | }); 315 | } 316 | }; 317 | 318 | const handlePauseAlerts = async (hours) => { 319 | try { 320 | const pauseUntil = new Date(); 321 | pauseUntil.setHours(pauseUntil.getHours() + hours); 322 | 323 | const updatedSettings = { 324 | ...alertSettings, 325 | paused_until: pauseUntil.toISOString() 326 | }; 327 | 328 | await axios.put(`${API_URL}/alert-settings`, updatedSettings); 329 | setAlertSettings(updatedSettings); 330 | 331 | setAlert({ 332 | open: true, 333 | message: `Alerts paused for ${hours} hour${hours > 1 ? 's' : ''}`, 334 | severity: 'success' 335 | }); 336 | } catch (error) { 337 | console.error('Error pausing alerts:', error); 338 | setAlert({ 339 | open: true, 340 | message: 'Error pausing alerts', 341 | severity: 'error' 342 | }); 343 | } 344 | }; 345 | 346 | const handleResumeAlerts = async () => { 347 | try { 348 | const updatedSettings = { 349 | ...alertSettings, 350 | paused_until: null 351 | }; 352 | 353 | await axios.put(`${API_URL}/alert-settings`, updatedSettings); 354 | setAlertSettings(updatedSettings); 355 | 356 | setAlert({ 357 | open: true, 358 | message: 'Alerts resumed', 359 | severity: 'success' 360 | }); 361 | } catch (error) { 362 | console.error('Error resuming alerts:', error); 363 | setAlert({ 364 | open: true, 365 | message: 'Error resuming alerts', 366 | severity: 'error' 367 | }); 368 | } 369 | }; 370 | 371 | const handleClearAlertHistory = async () => { 372 | try { 373 | await axios.delete(`${API_URL}/alert-history`); 374 | setAlertHistory([]); 375 | setAlert({ 376 | open: true, 377 | message: 'Alert history cleared successfully', 378 | severity: 'success' 379 | }); 380 | } catch (error) { 381 | console.error('Error clearing alert history:', error); 382 | setAlert({ 383 | open: true, 384 | message: 'Error clearing alert history', 385 | severity: 'error' 386 | }); 387 | } 388 | }; 389 | 390 | const handleTestWebhook = async () => { 391 | try { 392 | const response = await axios.post(`${API_URL}/test-webhook`, { 393 | webhook_url: alertSettings.webhook_url 394 | }); 395 | 396 | setAlert({ 397 | open: true, 398 | message: response.data.message || 'Test webhook sent successfully!', 399 | severity: 'success' 400 | }); 401 | } catch (error) { 402 | console.error('Error testing webhook:', error); 403 | setAlert({ 404 | open: true, 405 | message: error.response?.data?.error || 'Error testing webhook', 406 | severity: 'error' 407 | }); 408 | } 409 | }; 410 | 411 | const formatAlertTime = (timestamp) => { 412 | return new Date(timestamp).toLocaleString(); 413 | }; 414 | 415 | const isAlertsPaused = () => { 416 | return alertSettings.paused_until && new Date(alertSettings.paused_until) > new Date(); 417 | }; 418 | 419 | return ( 420 | 421 | 422 | 428 | 435 | 436 | 437 | 438 | 439 | Dashboard Configuration 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 |
456 | 457 | 464 | 465 | 472 | 473 | 480 | 481 | 489 | {Object.entries(THEMES).map(([key, theme]) => ( 490 | {theme.name} 491 | ))} 492 | 493 | 494 | 501 | } 502 | label="Show Details" 503 | /> 504 | 505 | 512 | 513 | 520 | 521 | 528 | 529 | 536 | 537 | 545 | 546 |
547 |
548 | 549 | 550 | 551 | 558 | 559 | 560 | {pages.map((page) => ( 561 | 562 | 566 | 567 | handleEdit('pages', page)}> 568 | 569 | 570 | handleDelete('pages', page.id)}> 571 | 572 | 573 | 574 | 575 | ))} 576 | 577 | 578 | 579 | 580 | 581 | 588 | 589 | 590 | {groups.map((group) => { 591 | const page = pages.find(p => p.id === group.page_id); 592 | return ( 593 | 594 | 598 | 599 | handleEdit('groups', group)}> 600 | 601 | 602 | handleDelete('groups', group.id)}> 603 | 604 | 605 | 606 | 607 | ); 608 | })} 609 | 610 | 611 | 612 | 613 | 614 | 621 | 622 | 623 | {services.map((service) => ( 624 | 625 | 629 | 630 | handleEdit('services', service)}> 631 | 632 | 633 | handleDelete('services', service.id)}> 634 | 635 | 636 | 637 | 638 | ))} 639 | 640 | 641 | 642 | 643 | 644 | 651 | 652 | 653 | {widgets.map((widget) => { 654 | const page = pages.find(p => p.id === widget.page_id); 655 | return ( 656 | 657 | 661 | 662 | handleEdit('widgets', widget)}> 663 | 664 | 665 | handleDelete('widgets', widget.id)}> 666 | 667 | 668 | 669 | 670 | ); 671 | })} 672 | 673 | 674 | 675 | 676 | 677 | 684 | 685 | 686 | {icons.map((icon) => ( 687 | 688 | 689 | {icon.alt} 698 | 702 | 703 | handleEdit('icons', icon)}> 704 | 705 | 706 | handleDelete('icons', icon.id)}> 707 | 708 | 709 | 710 | 711 | 712 | ))} 713 | 714 | 715 | 716 | 717 | 718 | Alert Settings 719 | 720 | 721 | Note: Alerts only work when Service Mode theme is enabled. 722 | 723 | 724 | 725 | handleAlertSettingsChange('enabled', e.target.checked)} 730 | /> 731 | } 732 | label="Enable Alerts" 733 | /> 734 | 735 | handleAlertSettingsChange('down_threshold_minutes', parseInt(e.target.value) || 5)} 740 | helperText="How long a service must be down before sending an alert" 741 | inputProps={{ min: 1, max: 1440 }} 742 | /> 743 | 744 | 745 | 746 | Webhook Notifications 747 | 748 | handleAlertSettingsChange('webhook_enabled', e.target.checked)} 753 | /> 754 | } 755 | label="Enable Webhook Notifications" 756 | /> 757 | 758 | handleAlertSettingsChange('webhook_url', e.target.value)} 763 | placeholder="https://discord.com/api/webhooks/..." 764 | helperText="Discord webhook URL for sending notifications" 765 | disabled={!alertSettings.webhook_enabled} 766 | /> 767 | 768 | 769 | 775 | 782 | 783 | 784 | 785 | 786 | 787 | 788 | 789 | Alert Control 790 | 791 | {isAlertsPaused() && ( 792 | 793 | Alerts paused until {formatAlertTime(alertSettings.paused_until)} 794 | 795 | )} 796 | 797 | 798 | 799 | {!isAlertsPaused() ? ( 800 | <> 801 | 808 | 815 | 822 | 823 | ) : ( 824 | 831 | )} 832 | 833 | 834 | 835 | 836 | 837 | 838 | Alert History ({alertHistory.length} alerts) 839 | 840 | 848 | 849 | 850 | 851 | {alertHistory.length === 0 ? ( 852 | 853 | No alerts recorded yet 854 | 855 | ) : ( 856 | 857 | {alertHistory.map((alert) => ( 858 | 859 | 862 | 870 | 871 | {alert.service_name} 872 | 873 | 874 | ({alert.service_ip}:{alert.service_port}) 875 | 876 | 877 | } 878 | secondary={ 879 | 880 | 881 | {alert.message} 882 | 883 | 884 | {formatAlertTime(alert.created_at)} • 885 | Webhook: {alert.webhook_sent ? '✅ Sent' : '❌ Failed'} 886 | 887 | 888 | } 889 | /> 890 | 891 | ))} 892 | 893 | )} 894 | 895 | 896 |
897 | 898 | setDialogOpen(false)}> 899 | 900 | {editingItem ? `Edit ${dialogType.slice(0, -1)}` : `Add ${dialogType.slice(0, -1)}`} 901 | 902 | 903 | 904 | {dialogType === 'pages' && ( 905 | <> 906 | setEditingItem(prev => ({ ...prev, title: e.target.value }))} 912 | sx={{ mb: 2 }} 913 | /> 914 | setEditingItem(prev => ({ ...prev, display_order: parseInt(e.target.value, 10) }))} 920 | /> 921 | 922 | )} 923 | {dialogType === 'groups' && ( 924 | <> 925 | setEditingItem(prev => ({ ...prev, title: e.target.value }))} 931 | sx={{ mb: 2 }} 932 | /> 933 | setEditingItem(prev => ({ ...prev, page_id: parseInt(e.target.value, 10) }))} 939 | sx={{ mb: 2 }} 940 | > 941 | {pages.map((page) => ( 942 | 943 | {page.title} 944 | 945 | ))} 946 | 947 | setEditingItem(prev => ({ ...prev, display_order: parseInt(e.target.value, 10) }))} 953 | /> 954 | 955 | )} 956 | {dialogType === 'services' && ( 957 | <> 958 | setEditingItem(prev => ({ ...prev, name: e.target.value }))} 964 | sx={{ mb: 2 }} 965 | /> 966 | setEditingItem(prev => ({ ...prev, url: e.target.value }))} 971 | sx={{ mb: 2 }} 972 | /> 973 | setEditingItem(prev => ({ ...prev, icon_url: e.target.value }))} 978 | sx={{ mb: 2 }} 979 | /> 980 | setEditingItem(prev => ({ ...prev, ip: e.target.value || null }))} 985 | sx={{ mb: 2 }} 986 | /> 987 | setEditingItem(prev => ({ ...prev, port: e.target.value ? parseInt(e.target.value, 10) : null }))} 993 | sx={{ mb: 2 }} 994 | /> 995 | setEditingItem(prev => ({ ...prev, group_id: parseInt(e.target.value, 10) }))} 1001 | sx={{ mb: 2 }} 1002 | > 1003 | {groups.map((group) => ( 1004 | 1005 | {group.title} 1006 | 1007 | ))} 1008 | 1009 | setEditingItem(prev => ({ ...prev, display_order: parseInt(e.target.value, 10) }))} 1015 | sx={{ mb: 2 }} 1016 | /> 1017 | 1018 | Alert Settings 1019 | setEditingItem(prev => ({ ...prev, alert_enabled: e.target.checked }))} 1024 | /> 1025 | } 1026 | label="Enable alerts for this service" 1027 | sx={{ mb: 2 }} 1028 | /> 1029 | setEditingItem(prev => ({ ...prev, down_threshold_minutes: e.target.value ? parseInt(e.target.value, 10) : null }))} 1035 | helperText="Override global down threshold for this service" 1036 | inputProps={{ min: 1, max: 1440 }} 1037 | /> 1038 | 1039 | )} 1040 | {dialogType === 'widgets' && ( 1041 | <> 1042 | setEditingItem(prev => ({ ...prev, type: e.target.value }))} 1048 | sx={{ mb: 2 }} 1049 | > 1050 | Date/Time 1051 | Weather 1052 | Sun Position 1053 | 1054 | setEditingItem(prev => ({ ...prev, title: e.target.value }))} 1059 | sx={{ mb: 2 }} 1060 | /> 1061 | setEditingItem(prev => ({ ...prev, page_id: parseInt(e.target.value, 10) }))} 1067 | sx={{ mb: 2 }} 1068 | > 1069 | {pages.map((page) => ( 1070 | 1071 | {page.title} 1072 | 1073 | ))} 1074 | 1075 | setEditingItem(prev => ({ ...prev, display_order: parseInt(e.target.value, 10) }))} 1081 | sx={{ mb: 2 }} 1082 | /> 1083 | 1084 | Widget Configuration (JSON) 1085 | { 1091 | const value = e.target.value; 1092 | try { 1093 | const config = JSON.parse(value); 1094 | setEditingItem(prev => ({ ...prev, config, configString: undefined })); 1095 | } catch (err) { 1096 | // Invalid JSON, keep the string for user to fix 1097 | setEditingItem(prev => ({ ...prev, configString: value })); 1098 | } 1099 | }} 1100 | placeholder='{"apiKey": "your-api-key", "location": "City, Country"}' 1101 | helperText="Enter widget configuration as JSON. See documentation for widget-specific options." 1102 | error={editingItem?.configString !== undefined} 1103 | /> 1104 | 1105 | setEditingItem(prev => ({ ...prev, enabled: e.target.checked }))} 1110 | /> 1111 | } 1112 | label="Enabled" 1113 | /> 1114 | 1115 | )} 1116 | {dialogType === 'icons' && ( 1117 | <> 1118 | setEditingItem(prev => ({ ...prev, alt: e.target.value }))} 1124 | sx={{ mb: 2 }} 1125 | /> 1126 | setEditingItem(prev => ({ ...prev, iconUrl: e.target.value }))} 1131 | sx={{ mb: 2 }} 1132 | /> 1133 | setEditingItem(prev => ({ ...prev, link: e.target.value }))} 1138 | sx={{ mb: 2 }} 1139 | /> 1140 | setEditingItem(prev => ({ ...prev, display_order: parseInt(e.target.value, 10) }))} 1146 | /> 1147 | 1148 | )} 1149 | 1150 | 1151 | 1152 | 1153 | 1156 | 1157 | 1158 | 1159 | setAlert({ ...alert, open: false })} 1163 | > 1164 | setAlert({ ...alert, open: false })} 1166 | severity={alert.severity} 1167 | > 1168 | {alert.message} 1169 | 1170 | 1171 |
1172 | ); 1173 | }; 1174 | 1175 | export default ConfigurationPage; --------------------------------------------------------------------------------