Add service grouping feature to README and implement dynamic rendering in index.html
This commit is contained in:
90
README.md
90
README.md
@@ -19,6 +19,7 @@ A lightweight, self-hosted dashboard for quick access to your Docker services wi
|
|||||||
- 🎮 **Keyboard Navigation** - Arrow keys to navigate, Enter to open, Esc to clear
|
- 🎮 **Keyboard Navigation** - Arrow keys to navigate, Enter to open, Esc to clear
|
||||||
- 🟢 **Status Indicators** - Optional visual status (online/offline/maintenance)
|
- 🟢 **Status Indicators** - Optional visual status (online/offline/maintenance)
|
||||||
- 🏥 **Automatic Health Checks** - Real-time ping tests to detect service availability
|
- 🏥 **Automatic Health Checks** - Real-time ping tests to detect service availability
|
||||||
|
- 📂 **Service Groups** - Organize services into categorized sections
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -32,23 +33,39 @@ The homepage will be available at `http://localhost:8088`
|
|||||||
|
|
||||||
### 2. Configure Your Services
|
### 2. Configure Your Services
|
||||||
|
|
||||||
Edit `services.xml` to add your services:
|
Edit `services.xml` to add your services organized into groups:
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<services>
|
<services>
|
||||||
<service
|
<group name="Management">
|
||||||
name="Portainer"
|
<service
|
||||||
proto="https"
|
name="Portainer"
|
||||||
host="portainer.example.com"
|
proto="https"
|
||||||
logo="portainer.svg"
|
port="9443"
|
||||||
/>
|
logo="portainer.svg"
|
||||||
<service
|
/>
|
||||||
name="Jellyfin"
|
</group>
|
||||||
proto="http"
|
|
||||||
port="8096"
|
<group name="Media">
|
||||||
logo="jellyfin.svg"
|
<service
|
||||||
/>
|
name="Jellyfin"
|
||||||
|
proto="http"
|
||||||
|
port="8096"
|
||||||
|
host="jellyfin.example.com"
|
||||||
|
logo="jellyfin.svg"
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</services>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use services without groups (they'll appear in a single grid):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<services>
|
||||||
|
<service name="Portainer" proto="https" port="9443" logo="portainer.svg" />
|
||||||
|
<service name="Jellyfin" proto="http" port="8096" logo="jellyfin.svg" />
|
||||||
</services>
|
</services>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -146,6 +163,44 @@ Status colors:
|
|||||||
- Set `check-health="false"` to disable checking for specific services
|
- Set `check-health="false"` to disable checking for specific services
|
||||||
- No server-side component needed - runs entirely in the browser
|
- No server-side component needed - runs entirely in the browser
|
||||||
|
|
||||||
|
## Service Groups
|
||||||
|
|
||||||
|
Organize your services into categorized sections for better organization:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<services>
|
||||||
|
<group name="Management">
|
||||||
|
<service name="Portainer" proto="https" port="9443" logo="portainer.svg" />
|
||||||
|
<service name="Uptime Kuma" proto="http" port="3001" logo="uptime-kuma.svg" />
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="Media">
|
||||||
|
<service name="Jellyfin" proto="http" port="8096" logo="jellyfin.svg" />
|
||||||
|
<service name="Transmission" proto="http" port="9091" logo="transmission.svg" />
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="Storage">
|
||||||
|
<service name="Nextcloud" host="cloud.example.com" logo="nextcloud.svg" />
|
||||||
|
<service name="FileBrowser" proto="http" port="8986" logo="filebrowser.svg" />
|
||||||
|
</group>
|
||||||
|
</services>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Group Features
|
||||||
|
|
||||||
|
- **Categorization**: Group related services together (Media, Management, Development, etc.)
|
||||||
|
- **Visual Separation**: Each group has a styled header with an accent underline
|
||||||
|
- **Smart Search**: Searching filters services and hides empty groups automatically
|
||||||
|
- **Backward Compatible**: Services without groups still work (displayed in a single grid)
|
||||||
|
- **Flexible**: Use as many or as few groups as needed
|
||||||
|
|
||||||
|
### Group Tips
|
||||||
|
|
||||||
|
- Use clear, descriptive group names (e.g., "Media Services" instead of "Group 1")
|
||||||
|
- Keep related services together for easier navigation
|
||||||
|
- Groups appear in the order defined in the XML
|
||||||
|
- Empty groups (no services) are automatically hidden
|
||||||
|
|
||||||
## Icon Management
|
## Icon Management
|
||||||
|
|
||||||
### Using Included Icons
|
### Using Included Icons
|
||||||
@@ -370,15 +425,18 @@ Contributions welcome! Completed features:
|
|||||||
- ✅ Search/filter functionality with keyboard shortcut
|
- ✅ Search/filter functionality with keyboard shortcut
|
||||||
- ✅ Keyboard navigation (arrow keys, Enter, Esc)
|
- ✅ Keyboard navigation (arrow keys, Enter, Esc)
|
||||||
- ✅ Service status indicators (online/offline/maintenance)
|
- ✅ Service status indicators (online/offline/maintenance)
|
||||||
|
- ✅ Automatic health checks with ping tests
|
||||||
- ✅ Info button showing connection details
|
- ✅ Info button showing connection details
|
||||||
|
- ✅ Service groups/categories
|
||||||
|
|
||||||
Areas for future improvement:
|
Areas for future improvement:
|
||||||
|
|
||||||
- Service health checks (automatic ping/availability detection)
|
- Service health checks via custom endpoints
|
||||||
- Drag-and-drop reordering
|
- Drag-and-drop reordering within groups
|
||||||
- Categorized sections/groups
|
- Collapsible group sections
|
||||||
- Custom themes/color schemes
|
- Custom themes/color schemes
|
||||||
- Export/import service configurations
|
- Export/import service configurations
|
||||||
|
- Dashboard widgets (time, weather, etc.)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
83
index.html
83
index.html
@@ -16,8 +16,8 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section class="grid" id="services-grid">
|
<section id="services-container">
|
||||||
<!-- Services will be populated dynamically from /services.xml -->
|
<!-- Service groups will be populated dynamically from /services.xml -->
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="notes">
|
<section class="notes">
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<script>
|
<script>
|
||||||
// Fetch services.xml and render the service cards with logos.
|
// Fetch services.xml and render the service cards with logos.
|
||||||
(async function(){
|
(async function(){
|
||||||
const grid = document.getElementById('services-grid');
|
const container = document.getElementById('services-container');
|
||||||
const searchInput = document.getElementById('search-input');
|
const searchInput = document.getElementById('search-input');
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
let allServices = []; // Store all service elements for filtering
|
let allServices = []; // Store all service elements for filtering
|
||||||
@@ -46,9 +46,61 @@
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(text, 'application/xml');
|
const doc = parser.parseFromString(text, 'application/xml');
|
||||||
const services = Array.from(doc.getElementsByTagName('service'));
|
|
||||||
if(services.length===0){ grid.innerHTML = '<p class="notes">No services found in services.xml</p>'; return; }
|
// Check if we have groups or just services
|
||||||
services.forEach(s=>{
|
const groups = Array.from(doc.getElementsByTagName('group'));
|
||||||
|
const hasGroups = groups.length > 0;
|
||||||
|
|
||||||
|
if(hasGroups){
|
||||||
|
// Render grouped services
|
||||||
|
groups.forEach(group => {
|
||||||
|
const groupName = group.getAttribute('name') || 'Services';
|
||||||
|
const services = Array.from(group.getElementsByTagName('service'));
|
||||||
|
|
||||||
|
if(services.length === 0) return;
|
||||||
|
|
||||||
|
// Create group section
|
||||||
|
const groupSection = document.createElement('section');
|
||||||
|
groupSection.className = 'service-group';
|
||||||
|
|
||||||
|
const groupHeader = document.createElement('h2');
|
||||||
|
groupHeader.className = 'group-header';
|
||||||
|
groupHeader.textContent = groupName;
|
||||||
|
groupSection.appendChild(groupHeader);
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'grid';
|
||||||
|
|
||||||
|
services.forEach(s => {
|
||||||
|
const card = createServiceCard(s, host, allServices);
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
groupSection.appendChild(grid);
|
||||||
|
container.appendChild(groupSection);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: render ungrouped services
|
||||||
|
const services = Array.from(doc.getElementsByTagName('service'));
|
||||||
|
if(services.length === 0){
|
||||||
|
container.innerHTML = '<p class="notes">No services found in services.xml</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'grid';
|
||||||
|
grid.id = 'services-grid';
|
||||||
|
|
||||||
|
services.forEach(s => {
|
||||||
|
const card = createServiceCard(s, host, allServices);
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to create a service card
|
||||||
|
function createServiceCard(s, host, allServices) {
|
||||||
const name = s.getAttribute('name') || s.getAttribute('id') || 'unknown';
|
const name = s.getAttribute('name') || s.getAttribute('id') || 'unknown';
|
||||||
const proto = s.getAttribute('proto') || 'http';
|
const proto = s.getAttribute('proto') || 'http';
|
||||||
const port = s.getAttribute('port') || '';
|
const port = s.getAttribute('port') || '';
|
||||||
@@ -170,14 +222,18 @@
|
|||||||
a.appendChild(infoBtn);
|
a.appendChild(infoBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.appendChild(a);
|
|
||||||
allServices.push(a);
|
allServices.push(a);
|
||||||
});
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', (e) => {
|
||||||
const searchTerm = e.target.value.toLowerCase().trim();
|
const searchTerm = e.target.value.toLowerCase().trim();
|
||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
|
|
||||||
|
// Also hide/show group headers
|
||||||
|
const groupSections = container.querySelectorAll('.service-group');
|
||||||
|
|
||||||
allServices.forEach(card => {
|
allServices.forEach(card => {
|
||||||
const serviceName = card.dataset.serviceName;
|
const serviceName = card.dataset.serviceName;
|
||||||
if(serviceName.includes(searchTerm)){
|
if(serviceName.includes(searchTerm)){
|
||||||
@@ -187,14 +243,21 @@
|
|||||||
card.style.display = 'none';
|
card.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hide empty groups
|
||||||
|
groupSections.forEach(section => {
|
||||||
|
const visibleCards = section.querySelectorAll('.card:not([style*="display: none"])');
|
||||||
|
section.style.display = visibleCards.length > 0 ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
// Show message if no results
|
// Show message if no results
|
||||||
const existingMsg = grid.querySelector('.no-results');
|
const existingMsg = container.querySelector('.no-results');
|
||||||
if(existingMsg) existingMsg.remove();
|
if(existingMsg) existingMsg.remove();
|
||||||
if(visibleCount === 0 && searchTerm !== ''){
|
if(visibleCount === 0 && searchTerm !== ''){
|
||||||
const msg = document.createElement('p');
|
const msg = document.createElement('p');
|
||||||
msg.className = 'notes no-results';
|
msg.className = 'notes no-results';
|
||||||
msg.textContent = `No services found matching "${e.target.value}"`;
|
msg.textContent = `No services found matching "${e.target.value}"`;
|
||||||
grid.appendChild(msg);
|
container.appendChild(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
1
logos/simple-icons.manual-backup-20251123-234134
Submodule
1
logos/simple-icons.manual-backup-20251123-234134
Submodule
Submodule logos/simple-icons.manual-backup-20251123-234134 added at 3dd8ab9a29
@@ -12,6 +12,9 @@ header h1{margin:0;font-size:28px}
|
|||||||
#search-input::placeholder{color:var(--muted)}
|
#search-input::placeholder{color:var(--muted)}
|
||||||
#search-input:focus{border-color:rgba(79,70,229,0.5);box-shadow:0 0 0 3px rgba(79,70,229,0.2);background:rgba(255,255,255,0.08)}
|
#search-input:focus{border-color:rgba(79,70,229,0.5);box-shadow:0 0 0 3px rgba(79,70,229,0.2);background:rgba(255,255,255,0.08)}
|
||||||
main{max-width:1100px;margin:18px auto;padding:12px}
|
main{max-width:1100px;margin:18px auto;padding:12px}
|
||||||
|
.service-group{margin-bottom:32px}
|
||||||
|
.group-header{font-size:18px;font-weight:600;color:#e6eef8;margin:0 0 12px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,0.1);position:relative}
|
||||||
|
.group-header::before{content:'';position:absolute;bottom:-1px;left:0;width:60px;height:2px;background:linear-gradient(90deg,rgba(79,70,229,0.8),rgba(139,92,246,0.5));border-radius:2px}
|
||||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
|
||||||
.card{display:flex;align-items:center;justify-content:center;padding:18px;border-radius:10px;background:linear-gradient(135deg, rgba(79,70,229,0.1), rgba(139,92,246,0.05), rgba(236,72,153,0.1));text-decoration:none;color:inherit;border:1px solid rgba(255,255,255,0.15);font-weight:600;transition:transform .3s ease,box-shadow .3s ease,border-color .3s ease;position:relative;overflow:hidden;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
|
.card{display:flex;align-items:center;justify-content:center;padding:18px;border-radius:10px;background:linear-gradient(135deg, rgba(79,70,229,0.1), rgba(139,92,246,0.05), rgba(236,72,153,0.1));text-decoration:none;color:inherit;border:1px solid rgba(255,255,255,0.15);font-weight:600;transition:transform .3s ease,box-shadow .3s ease,border-color .3s ease;position:relative;overflow:hidden;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
|
||||||
.card::before{content:'';position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:linear-gradient(45deg,transparent 30%,rgba(255,255,255,0.08) 50%,transparent 70%);transform:rotate(45deg);animation:shimmer 8s infinite linear;pointer-events:none}
|
.card::before{content:'';position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:linear-gradient(45deg,transparent 30%,rgba(255,255,255,0.08) 50%,transparent 70%);transform:rotate(45deg);animation:shimmer 8s infinite linear;pointer-events:none}
|
||||||
|
|||||||
Reference in New Issue
Block a user