Add per-integration custom icon + multi-repo fallback chain for Node Status (#40)
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. Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9f10e8ee6f
commit
4f765b512a
4 changed files with 105 additions and 14 deletions
|
|
@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue