diff --git a/backend/src/routes/integrations.ts b/backend/src/routes/integrations.ts index 9d68dee..0cafa3f 100644 --- a/backend/src/routes/integrations.ts +++ b/backend/src/routes/integrations.ts @@ -143,7 +143,7 @@ export async function integrationRoutes(app: FastifyInstance) { app.get('/api/integrations/resources', async () => { const rows = db.prepare("SELECT * FROM integrations WHERE enabled = 1 AND status = 'connected'").all() as IntegrationRow[] - const resources: (Resource & { integration: string; integrationType: string })[] = [] + const resources: (Resource & { integration: string; integrationType: string; iconUrl?: string })[] = [] for (const row of rows) { const adapter = adapterRegistry[row.type as IntegrationType] if (!adapter.listResources) continue @@ -151,7 +151,7 @@ export async function integrationRoutes(app: FastifyInstance) { const secrets = loadSecrets(row.id) try { const found = await adapter.listResources(config, secrets) - for (const r of found) resources.push({ ...r, integration: row.name, integrationType: row.type }) + for (const r of found) resources.push({ ...r, integration: row.name, integrationType: row.type, iconUrl: config.iconUrl || undefined }) } catch (err) { app.log.warn(`listResources failed for integration "${row.name}" (${row.type}): ${err instanceof Error ? err.message : err}`) } diff --git a/src/lib/api.ts b/src/lib/api.ts index dbbce05..c961e59 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -445,6 +445,7 @@ export interface Resource { integration: string integrationType: string kind?: 'vm' | 'container' | 'app' | 'host' | 'network' + iconUrl?: string } export interface TransferProgress { diff --git a/src/pages/Infrastructure.tsx b/src/pages/Infrastructure.tsx index b087bc9..5d6f41e 100644 --- a/src/pages/Infrastructure.tsx +++ b/src/pages/Infrastructure.tsx @@ -76,12 +76,22 @@ const integrationPalette = ['#C8A434', '#E67E22', '#2ECC71', '#7A7D85', '#3B82F6 // per-integration tabs that will show every node, not just the grouped tile. const ungroupedIntegrationTypes = new Set(['proxmox']) -const cdnIconByIntegrationType: Record = { - uptime_kuma: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png', - aws: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/aws.png', - docker: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/docker.png', - netbird: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/netbird.png', - cloudflare: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/cloudflare.png', +// Icon lookup per integration type, ordered by preference. A user-supplied +// icon (Settings → Integrations → "Icon" — URL or upload, stored as +// config.iconUrl) always wins; these are the built-in fallback chain when +// no custom icon is set. Each entry is tried in order — on a failed image +// load (404/network) the next URL in the list is tried — and if every +// candidate fails, the per-kind Lucide icon (kindIcon) renders instead. +const cdnIconCandidatesByIntegrationType: Record = { + uptime_kuma: ['https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png'], + aws: [ + 'https://samuelsjames.github.io/assets-public/logos/aws-logo.svg', + 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/aws.png', + ], + docker: ['https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/docker.png'], + netbird: ['https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/netbird.png'], + cloudflare: ['https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/cloudflare.png'], + ssh: ['https://samuelsjames.github.io/assets-public/logos/linux-logo.svg'], } interface NodeGroup { @@ -90,6 +100,7 @@ interface NodeGroup { integrationType: string kind: string status: Resource['status'] + iconUrl?: string members: Resource[] } @@ -103,6 +114,26 @@ const kindIcon: Record = { network: Waypoints, } +// Tries the user's custom icon first, then each built-in candidate URL in +// order, falling back to the per-kind Lucide icon if every image 404s. +function TileIcon({ customUrl, candidates, fallback: Fallback }: { customUrl?: string; candidates: string[]; fallback: LucideIcon }) { + const urls = useMemo(() => [...(customUrl ? [customUrl] : []), ...candidates], [customUrl, candidates]) + const [failedCount, setFailedCount] = useState(0) + useEffect(() => setFailedCount(0), [urls]) + if (failedCount >= urls.length) return + return ( + setFailedCount((n) => n + 1)} + /> + ) +} + function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) { const hasData = data.some((d) => d.value > 0) return ( @@ -192,6 +223,7 @@ export default function Infrastructure() { integration, integrationType: members[0]?.integrationType ?? '', kind: members[0]?.kind ?? '', + iconUrl: members[0]?.iconUrl, status: members.some((m) => m.status === 'critical') ? 'critical' : members.some((m) => m.status === 'warning') @@ -317,7 +349,6 @@ export default function Infrastructure() { {nodeTiles.length > 0 ? (
{nodeTiles.map((node, i) => { - const cdnIcon = cdnIconByIntegrationType[node.integrationType] const NodeIcon = kindIcon[node.kind ?? ''] ?? Server const label = 'isGroup' in node ? node.integration : node.name const tooltip = 'isGroup' in node ? `${node.integration}: ${node.members.length} monitored` : `${node.name}: ${node.detail ?? node.status}` @@ -339,11 +370,11 @@ export default function Infrastructure() { >
- {cdnIcon ? ( - - ) : ( - - )} +
{label}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 61c7ca5..5681009 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -856,6 +856,54 @@ curl http://${info.host}:${info.port}/version` ) } +// Universal per-integration icon override — applies to every integration type, not +// just one. Stored as config.iconUrl and takes priority over the built-in CDN icon +// chain in Infrastructure.tsx's Node Status tile. Accepts a pasted URL or an +// uploaded image file (embedded as a base64 data URI — no separate file storage +// needed since config_json already holds arbitrary string values). +function IconField({ value, onChange }: { value: string; onChange: (value: string) => void }) { + const fileInputRef = useRef(null) + return ( +
+ +
+ {value && ( + + )} + onChange(e.target.value)} + placeholder="Paste an image URL, or upload a file" + /> + { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = () => onChange(String(reader.result ?? '')) + reader.readAsDataURL(file) + e.target.value = '' + }} + /> + +
+
+ ) +} + function IntegrationsSection() { const { user } = useAuth() const isAdmin = user?.role === 'admin' @@ -915,6 +963,9 @@ function IntegrationsSection() { if (f.secret) secrets[f.key] = value else config[f.key] = value } + // iconUrl is universal across every integration type (not a per-type field), + // overriding the built-in Node Status icon for this integration. + if (values.iconUrl !== undefined) config.iconUrl = values.iconUrl return { config, secrets } } @@ -1123,6 +1174,10 @@ function IntegrationsSection() { '••••••••••••', existing, )} + setEditField(existing.id, 'iconUrl', value)} + /> {def.type === 'docker' && ( )} @@ -1175,6 +1230,10 @@ function IntegrationsSection() { (f, value) => setNewDraftField(draft.id, f.key, value), '', )} + setNewDraftField(draft.id, 'iconUrl', value)} + /> {def.type === 'docker' && } )