3 Commits

Author SHA1 Message Date
root
25ac261862 feat(discover): name service candidates by port-service + matched device at scan time
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:48:16 +10:00
root
15de56dbe6 feat(littleblue): discovered services show matching network-device name
Cross-references each candidate host IP with lan_devices (known) so a tile shows
e.g. 'H Tower' instead of '192.168.1.15:32400'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:45:56 +10:00
root
442bb6ccc9 feat(littleblue): edit (✎) affordance for service tiles + name 8 discovered services
Service tiles now have an inline edit (name/category/url/icon, save/delete) like the
Devices band — fixes 'can't edit after adding'. Touch-visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:39:43 +10:00
6 changed files with 71 additions and 9 deletions

View File

@@ -5,6 +5,7 @@ import { requireOwner } from '../cap.js';
import { validate } from '../validate.js'; import { validate } from '../validate.js';
import { grouped, iconSlug } from '../../health/registry.js'; import { grouped, iconSlug } from '../../health/registry.js';
import * as services from '../../db/repos/monitored_services.js'; import * as services from '../../db/repos/monitored_services.js';
import * as devices from '../../db/repos/lan_devices.js';
import * as statusRepo from '../../db/repos/service_status.js'; import * as statusRepo from '../../db/repos/service_status.js';
import { enqueue } from '../../jobs/queue.js'; import { enqueue } from '../../jobs/queue.js';
@@ -29,7 +30,13 @@ router.get('/services', asyncWrap(async (_req, res) => {
// GET /services/discovered — candidates from a LAN scan, awaiting review (owner). // GET /services/discovered — candidates from a LAN scan, awaiting review (owner).
router.get('/services/discovered', requireOwner, asyncWrap(async (_req, res) => { router.get('/services/discovered', requireOwner, asyncWrap(async (_req, res) => {
res.json((await services.listDiscovered()).map(s => ({ ...s, icon: iconSlug(s) }))); // Cross-reference each candidate's host IP with the Network Devices band so the
// tile can show a known device name instead of a bare IP:port.
const byIp = Object.fromEntries(
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
res.json((await services.listDiscovered()).map(s => ({
...s, icon: iconSlug(s), device: byIp[s.host] || null
})));
})); }));
const checkCfg = z.object({ type: z.enum(['http', 'tcp']).optional(), path: z.string().max(200).optional() }); const checkCfg = z.object({ type: z.enum(['http', 'tcp']).optional(), path: z.string().max(200).optional() });

View File

@@ -1,9 +1,18 @@
import net from 'node:net'; import net from 'node:net';
import * as services from '../../db/repos/monitored_services.js'; import * as services from '../../db/repos/monitored_services.js';
import * as devices from '../../db/repos/lan_devices.js';
import { log } from '../../log.js'; import { log } from '../../log.js';
export const NAME = 'discover.lan'; export const NAME = 'discover.lan';
// Well-known homelab ports → likely service, so candidates get a real name.
const PORT_SVC = {
2424: 'Void', 5055: 'Overseerr', 6767: 'Bazarr', 7878: 'Radarr', 8006: 'Proxmox VE',
8096: 'Jellyfin', 8123: 'Home Assistant', 8265: 'Tdarr', 8384: 'Syncthing', 8989: 'Sonarr',
9000: 'Portainer', 9090: 'Cockpit', 9696: 'Prowlarr', 11434: 'Ollama', 19999: 'Netdata',
32400: 'Plex'
};
// Common homelab web/service ports to probe. // Common homelab web/service ports to probe.
const PORTS = [80, 81, 443, 2424, 3000, 3001, 5000, 5055, 6767, 6875, 7878, 8000, const PORTS = [80, 81, 443, 2424, 3000, 3001, 5000, 5055, 6767, 6875, 7878, 8000,
8006, 8080, 8081, 8096, 8123, 8265, 8384, 8443, 8989, 9000, 9090, 9696, 11434, 19999, 32400, 60072]; 8006, 8080, 8081, 8096, 8123, 8265, 8384, 8443, 8989, 9000, 9090, 9696, 11434, 19999, 32400, 60072];
@@ -55,13 +64,18 @@ export async function handler(job) {
// 1) TCP sweep → live host:ports // 1) TCP sweep → live host:ports
const open = (await mapPool(targets, 120, async (t) => (await _tcp(t.host, t.port)) ? t : null)).filter(Boolean); const open = (await mapPool(targets, 120, async (t) => (await _tcp(t.host, t.port)) ? t : null)).filter(Boolean);
// 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo) // 2) HTTP-probe each, build + upsert discovered candidates (no-clobber in the repo).
// Cross-reference the Network Devices band so candidates are named by service+device.
const deviceByIp = Object.fromEntries(
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
let added = 0; let added = 0;
for (const { host, port } of open) { for (const { host, port } of open) {
const scheme = HTTPS_PORTS.has(port) ? 'https' : 'http'; const scheme = HTTPS_PORTS.has(port) ? 'https' : 'http';
const url = `${scheme}://${host}:${port}`; const url = `${scheme}://${host}:${port}`;
const probe = await _http(url); const probe = await _http(url);
const name = (probe && probe.title) || `${host}:${port}`; const dev = deviceByIp[host];
const svc = PORT_SVC[port] || (probe && probe.title) || null;
const name = svc ? (dev ? `${svc} · ${dev}` : svc) : (dev ? `${dev} :${port}` : `${host}:${port}`);
const id = `disc-${host.replace(/\./g, '-')}-${port}`; const id = `disc-${host.replace(/\./g, '-')}-${port}`;
const check = scheme === 'https' ? { type: 'tcp' } : { type: 'http' }; const check = scheme === 'https' ? { type: 'tcp' } : { type: 'http' };
const r = await services.upsertDiscovered({ id, name, category: 'other', host, url, check }); const r = await services.upsertDiscovered({ id, name, category: 'other', host, url, check });

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "void-server", "name": "void-server",
"version": "2.6.3", "version": "2.6.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "void-server", "name": "void-server",
"version": "2.6.3", "version": "2.6.6",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "void-server", "name": "void-server",
"version": "2.6.3", "version": "2.6.6",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -574,6 +574,16 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.dv-tile:hover .dv-edit-btn { opacity: 1; } .dv-tile:hover .dv-edit-btn { opacity: 1; }
/* touch devices have no hover — keep the ✎ edit button always visible there */ /* touch devices have no hover — keep the ✎ edit button always visible there */
@media (hover: none) { .dv-edit-btn { opacity: .85; } } @media (hover: none) { .dv-edit-btn { opacity: .85; } }
/* Little Blue service-tile edit affordance */
.lb-tile-wrap { position: relative; }
.lb-edit-btn { position: absolute; top: 5px; right: 5px; z-index: 5; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
.lb-tile-wrap:hover .lb-edit-btn { opacity: 1; }
.lb-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
@media (hover: none) { .lb-edit-btn { opacity: .85; } }
.lb-edit { display: flex; flex-direction: column; gap: 4px; padding: 8px; }
.lb-edit .dv-edit-name, .lb-edit .dv-edit-grp { width: 100%; margin: 0; }
.lb-edit-btns { display: flex; gap: 4px; margin-top: 2px; }
.lb-edit-btns button { font-size: 11px; padding: 2px 8px; }
.dv-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); } .dv-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
.dv-tile .dv-edit-name, .dv-tile .dv-edit-grp { margin: 2px 0; width: 100%; } .dv-tile .dv-edit-name, .dv-tile .dv-edit-grp { margin: 2px 0; width: 100%; }
.dv-tile .dv-add, .dv-tile .dv-ignore, .dv-tile .ghost { margin-top: 4px; margin-right: 4px; font-size: 11px; padding: 2px 8px; } .dv-tile .dv-add, .dv-tile .dv-ignore, .dv-tile .ghost { margin-top: 4px; margin-right: 4px; font-size: 11px; padding: 2px 8px; }

View File

@@ -5,6 +5,7 @@ import { serviceTile } from '../components/service_tile.js';
import { isRemoteHost } from './service_url.js'; import { isRemoteHost } from './service_url.js';
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' }; const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
const CATS = ['agents', 'infrastructure', 'media', 'other'];
let host, timer, scanning = false; let host, timer, scanning = false;
async function promote(id) { async function promote(id) {
@@ -17,6 +18,36 @@ function scan() {
setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s
} }
// Inline edit form for a service (name / category / url / icon) — PATCH or DELETE.
function editForm(s) {
const nameI = el('input', { class: 'dv-edit-name', value: s.name || '', placeholder: 'name' });
const catS = el('select', { class: 'dv-edit-grp' }, ...CATS.map(c => el('option', { value: c }, TITLE[c])));
catS.value = s.category || 'other';
const urlI = el('input', { class: 'dv-edit-name', value: s.url || '', placeholder: 'http://host:port' });
const iconI = el('input', { class: 'dv-edit-name', value: s.icon || '', placeholder: 'icon slug e.g. plex' });
const save = el('button', { class: 'dv-add' }, 'Save');
save.onclick = async () => {
const patch = { name: nameI.value.trim(), category: catS.value, url: urlI.value.trim() };
const ic = iconI.value.trim(); if (ic) patch.icon = ic;
try { await api.patch('/api/health/services/' + s.id, patch); load(); } catch { /* */ }
};
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
del.onclick = async () => { try { await api.del('/api/health/services/' + s.id); load(); } catch { /* */ } };
const cancel = el('button', { class: 'ghost' }, 'Cancel');
cancel.onclick = load;
return el('div', { class: 'tile lb-edit' }, nameI, catS, urlI, iconI,
el('div', { class: 'lb-edit-btns' }, save, del, cancel));
}
// A service tile wrapped with an ✎ edit button that swaps to the edit form.
function tileWithEdit(s, remote) {
const wrap = el('div', { class: 'lb-tile-wrap' });
const edit = el('button', { class: 'lb-edit-btn', title: 'Edit service' }, '✎');
edit.onclick = (e) => { e.preventDefault(); e.stopPropagation(); mount(wrap, editForm(s)); };
mount(wrap, serviceTile(s, remote), edit);
return wrap;
}
// Owner-only; returns a section element or null (skipped for non-owner / none). // Owner-only; returns a section element or null (skipped for non-owner / none).
async function discoveredSection() { async function discoveredSection() {
let cand; let cand;
@@ -30,8 +61,8 @@ async function discoveredSection() {
el('div', { class: 'tiles' }, cand.map(c => el('div', { class: 'tiles' }, cand.map(c =>
el('div', { class: 'tile disc' }, el('div', { class: 'tile disc' },
el('div', { class: 'tile-main' }, el('div', { class: 'tile-main' },
el('div', { class: 'tile-nm' }, c.name), el('div', { class: 'tile-nm' }, c.device || c.name),
el('div', { class: 'tile-host' }, c.url)), el('div', { class: 'tile-host' }, c.device ? `${c.name} · ${c.url}` : c.url)),
el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+'))))); el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+')))));
} }
@@ -46,7 +77,7 @@ async function load() {
el('span', { class: 'gname' }, TITLE[g.category] || g.category), el('span', { class: 'gname' }, TITLE[g.category] || g.category),
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`), el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
el('span', { class: 'line' })), el('span', { class: 'line' })),
el('div', { class: 'tiles' }, g.services.map(s => serviceTile(s, remote))))); el('div', { class: 'tiles' }, g.services.map(s => tileWithEdit(s, remote)))));
const disc = await discoveredSection(); const disc = await discoveredSection();
mount(host, mount(host,
el('div', { class: 'lbwrap' }, littleblueAvatar(), el('div', { class: 'lbwrap' }, littleblueAvatar(),