Add per-integration custom icon + multi-repo fallback chain for Node Status

Settings → Integrations now has a universal "Icon" field (any integration
type) accepting a pasted URL or an uploaded image, stored as config.iconUrl.
This overrides the built-in icon for that integration's Node Status tile.

Node Status tiles now resolve their icon through a priority chain: custom
iconUrl, then each built-in CDN candidate in order (assets-public first
where available, falling back to the existing dashboard-icons CDN), and
finally the generic per-kind Lucide icon if every candidate 404s. AWS now
tries samuelsjames.github.io/assets-public's aws-logo.svg before the
jsDelivr fallback; SSH gets a Linux logo from the same repo. Proxmox,
Weather, and Remote Desktop have no built-in candidates yet (no matching
assets in that repo) and fall back to the generic icon until added.
This commit is contained in:
Claude 2026-06-21 20:03:36 +00:00
parent 9f10e8ee6f
commit ba8683f5a8
No known key found for this signature in database
4 changed files with 105 additions and 14 deletions

View file

@ -143,7 +143,7 @@ export async function integrationRoutes(app: FastifyInstance) {
app.get('/api/integrations/resources', async () => { app.get('/api/integrations/resources', async () => {
const rows = db.prepare("SELECT * FROM integrations WHERE enabled = 1 AND status = 'connected'").all() as IntegrationRow[] 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) { for (const row of rows) {
const adapter = adapterRegistry[row.type as IntegrationType] const adapter = adapterRegistry[row.type as IntegrationType]
if (!adapter.listResources) continue if (!adapter.listResources) continue
@ -151,7 +151,7 @@ export async function integrationRoutes(app: FastifyInstance) {
const secrets = loadSecrets(row.id) const secrets = loadSecrets(row.id)
try { try {
const found = await adapter.listResources(config, secrets) 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) { } catch (err) {
app.log.warn(`listResources failed for integration "${row.name}" (${row.type}): ${err instanceof Error ? err.message : err}`) app.log.warn(`listResources failed for integration "${row.name}" (${row.type}): ${err instanceof Error ? err.message : err}`)
} }

View file

@ -445,6 +445,7 @@ export interface Resource {
integration: string integration: string
integrationType: string integrationType: string
kind?: 'vm' | 'container' | 'app' | 'host' | 'network' kind?: 'vm' | 'container' | 'app' | 'host' | 'network'
iconUrl?: string
} }
export interface TransferProgress { export interface TransferProgress {

View file

@ -76,12 +76,22 @@ const integrationPalette = ['#C8A434', '#E67E22', '#2ECC71', '#7A7D85', '#3B82F6
// per-integration tabs that will show every node, not just the grouped tile. // per-integration tabs that will show every node, not just the grouped tile.
const ungroupedIntegrationTypes = new Set(['proxmox']) const ungroupedIntegrationTypes = new Set(['proxmox'])
const cdnIconByIntegrationType: Record<string, string> = { // Icon lookup per integration type, ordered by preference. A user-supplied
uptime_kuma: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png', // icon (Settings → Integrations → "Icon" — URL or upload, stored as
aws: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/aws.png', // config.iconUrl) always wins; these are the built-in fallback chain when
docker: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/docker.png', // no custom icon is set. Each entry is tried in order — on a failed image
netbird: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/netbird.png', // load (404/network) the next URL in the list is tried — and if every
cloudflare: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/cloudflare.png', // candidate fails, the per-kind Lucide icon (kindIcon) renders instead.
const cdnIconCandidatesByIntegrationType: Record<string, string[]> = {
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 { interface NodeGroup {
@ -90,6 +100,7 @@ interface NodeGroup {
integrationType: string integrationType: string
kind: string kind: string
status: Resource['status'] status: Resource['status']
iconUrl?: string
members: Resource[] members: Resource[]
} }
@ -103,6 +114,26 @@ const kindIcon: Record<string, LucideIcon> = {
network: Waypoints, 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 <Fallback size={12} style={{ color: '#7A7D85' }} />
return (
<img
key={urls[failedCount]}
src={urls[failedCount]}
width={12}
height={12}
style={{ borderRadius: '2px' }}
alt=""
onError={() => setFailedCount((n) => n + 1)}
/>
)
}
function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) { function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) {
const hasData = data.some((d) => d.value > 0) const hasData = data.some((d) => d.value > 0)
return ( return (
@ -192,6 +223,7 @@ export default function Infrastructure() {
integration, integration,
integrationType: members[0]?.integrationType ?? '', integrationType: members[0]?.integrationType ?? '',
kind: members[0]?.kind ?? '', kind: members[0]?.kind ?? '',
iconUrl: members[0]?.iconUrl,
status: members.some((m) => m.status === 'critical') status: members.some((m) => m.status === 'critical')
? 'critical' ? 'critical'
: members.some((m) => m.status === 'warning') : members.some((m) => m.status === 'warning')
@ -317,7 +349,6 @@ export default function Infrastructure() {
{nodeTiles.length > 0 ? ( {nodeTiles.length > 0 ? (
<div className="scrollbar-ghost grid min-h-0 flex-1 grid-cols-5 content-start gap-2" style={{ overflowY: 'auto' }}> <div className="scrollbar-ghost grid min-h-0 flex-1 grid-cols-5 content-start gap-2" style={{ overflowY: 'auto' }}>
{nodeTiles.map((node, i) => { {nodeTiles.map((node, i) => {
const cdnIcon = cdnIconByIntegrationType[node.integrationType]
const NodeIcon = kindIcon[node.kind ?? ''] ?? Server const NodeIcon = kindIcon[node.kind ?? ''] ?? Server
const label = 'isGroup' in node ? node.integration : node.name 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}` 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() {
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: nodeStatusColor[node.status], boxShadow: `0 0 6px ${nodeStatusColor[node.status]}` }} /> <span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: nodeStatusColor[node.status], boxShadow: `0 0 6px ${nodeStatusColor[node.status]}` }} />
{cdnIcon ? ( <TileIcon
<img src={cdnIcon} width={12} height={12} style={{ borderRadius: '2px' }} alt="" /> customUrl={node.iconUrl}
) : ( candidates={cdnIconCandidatesByIntegrationType[node.integrationType] ?? []}
<NodeIcon size={12} style={{ color: '#7A7D85' }} /> fallback={NodeIcon}
)} />
</div> </div>
<span style={{ fontSize: '11px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span> <span style={{ fontSize: '11px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
</div> </div>

View file

@ -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<HTMLInputElement>(null)
return (
<div style={{ marginTop: '12px' }}>
<label style={labelStyle}>Icon (Node Status tile)</label>
<div className="flex items-center gap-2">
{value && (
<img src={value} width={20} height={20} style={{ borderRadius: '4px', flexShrink: 0 }} alt="" />
)}
<input
style={{ ...inputStyle, flex: 1 }}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Paste an image URL, or upload a file"
/>
<input
type="file"
ref={fileInputRef}
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
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 = ''
}}
/>
<button
onClick={() => fileInputRef.current?.click()}
title="Upload an icon file"
className="cursor-pointer border-none flex-shrink-0"
style={{ fontSize: '11px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '6px', padding: '8px 10px' }}
>
<Upload size={13} />
</button>
</div>
</div>
)
}
function IntegrationsSection() { function IntegrationsSection() {
const { user } = useAuth() const { user } = useAuth()
const isAdmin = user?.role === 'admin' const isAdmin = user?.role === 'admin'
@ -915,6 +963,9 @@ function IntegrationsSection() {
if (f.secret) secrets[f.key] = value if (f.secret) secrets[f.key] = value
else config[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 } return { config, secrets }
} }
@ -1123,6 +1174,10 @@ function IntegrationsSection() {
'••••••••••••', '••••••••••••',
existing, existing,
)} )}
<IconField
value={draft.iconUrl ?? existing.config.iconUrl ?? ''}
onChange={(value) => setEditField(existing.id, 'iconUrl', value)}
/>
{def.type === 'docker' && ( {def.type === 'docker' && (
<DockerSetupHint baseUrl={draft.baseUrl ?? existing.config.baseUrl ?? ''} /> <DockerSetupHint baseUrl={draft.baseUrl ?? existing.config.baseUrl ?? ''} />
)} )}
@ -1175,6 +1230,10 @@ function IntegrationsSection() {
(f, value) => setNewDraftField(draft.id, f.key, value), (f, value) => setNewDraftField(draft.id, f.key, value),
'', '',
)} )}
<IconField
value={draft.values.iconUrl ?? ''}
onChange={(value) => setNewDraftField(draft.id, 'iconUrl', value)}
/>
{def.type === 'docker' && <DockerSetupHint baseUrl={draft.values.baseUrl ?? ''} />} {def.type === 'docker' && <DockerSetupHint baseUrl={draft.values.baseUrl ?? ''} />}
</div> </div>
) )