Skip to content

Commit 1982e21

Browse files
committed
frontend: App: service: Add a8r.io service metadata support and icons
1 parent d9a3326 commit 1982e21

21 files changed

+2840
-19
lines changed

frontend/src/components/App/icons.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,30 @@ const mdiIcons = {
447447
'folder-open': {
448448
body: '\u003Cpath fill="currentColor" d="M19 20H4a2 2 0 0 1-2-2V6c0-1.11.89-2 2-2h6l2 2h7a2 2 0 0 1 2 2H4v10l2.14-8h17.07l-2.28 8.5c-.23.87-1.01 1.5-1.93 1.5"/\u003E',
449449
},
450+
sitemap: {
451+
body: '<path fill="currentColor" d="M9 2v6H5V2h4m3 11V9h2v4H9m6 0v-4h2v4h-2M5 16h4v6H5v-6m10 0h4v6h-4v-6M11 2v6h2V2h-2m4 0v6h2V2h-2m-8 7v4H5v2h14v-2h-2V9H7m4 7v6h2v-6h-2z"/>',
452+
},
453+
chat: {
454+
body: '<path fill="currentColor" d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m0 14H5.17L4 17.17V4h16z"/>',
455+
},
456+
'chart-timeline-variant': {
457+
body: '<path fill="currentColor" d="M2 2h2v20H2V2m4 10h14v2H6v-2m0-4h14v2H6V8m0 8h14v2H6v-2Z"/>',
458+
},
459+
'book-open-page-variant': {
460+
body: '<path fill="currentColor" d="M20 3H11V19H20V3M9 3H4V19H9V3M20 1H11C10.5 1 10 1.5 10 2V19C10 20.1 10.9 21 12 21H20C21.1 21 22 20.1 22 19V3C22 1.9 21.1 1 20 1M9 21H4C2.9 21 2 20.1 2 19V3C2 1.9 2.9 1 4 1H9V21Z"/>',
461+
},
462+
github: {
463+
body: '<path fill="currentColor" d="M12 2A10 10 0 0 0 2 12C2 16.42 4.87 20.17 8.84 21.5C9.34 21.58 9.5 21.27 9.5 21.03C9.5 20.82 9.5 20.24 9.5 19.5C6.71 20.14 6.13 18.19 6.13 18.19C5.67 17.03 5 16.72 5 16.72C4.08 16.1 5.07 16.11 5.07 16.11C6.08 16.18 6.61 17.15 6.61 17.15C7.5 18.68 8.95 18.23 9.5 17.97C9.6 17.33 9.85 16.89 10.13,16.64C7.91 16.39 5.58 15.53 5.58 11.7C5.58 10.6 5.97 9.71 6.62 9.01C6.51 8.76 6.17 7.72 6.72 6.34C6.72 6.34 7.57 6.07 9.5 7.37C10.3 7.15 11.17 7.04 12 7.04C12.83 7.04 13.7 7.15 14.5 7.37C16.42 6.07 17.27 6.34 17.27 6.34C17.82 7.72 17.48 8.76 17.37 9.01C18.03 9.71 18.42 10.6 18.42 11.7C18.42 15.54 16.08 16.39 13.85 16.64C14.21 16.96 14.5 17.58 14.5 18.53C14.5 19.89 14.5 21 14.5 21.32C14.5 21.56 14.65 21.85 15.15 21.75C19.13 20.42 22 16.5 22 12A10 10 0 0 0 12 2Z"/>',
464+
},
465+
'help-circle': {
466+
body: '<path fill="currentColor" d="M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m-1-4h2v2h-2zm1.61-9.96c-1.55 0-2.61.9-2.61 2.21h2c0-.59.44-.98 1.09-.98c.64 0 1.1.33 1.1.88c0 .48-.23.74-.86 1.13c-.86.52-1.34 1.2-1.34 2.15V12h2v-.39c0-.44.18-.68.76-1.04c.82-.51 1.45-1.21 1.45-2.28c0-1.72-1.45-2.65-3.09-2.65Z"/>',
467+
},
468+
'script-text': {
469+
body: '<path fill="currentColor" d="M18 19H6c-1.1 0-2-.9-2-2V7c0-1.1.9-2 2-2h12c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2m-5-9H7v2h6v-2m4 4H7v2h10v-2m0-8H7v2h10V7Z"/>',
470+
},
471+
speedometer: {
472+
body: '<path fill="currentColor" d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m0 2c4.4 0 8 3.6 8 8c0 1.8-.6 3.5-1.6 4.8l-1.5-1.5c.7-.9 1.1-2.1 1.1-3.3c0-3.3-2.7-6-6-6s-6 2.7-6 6c0 1.2.4 2.4 1.1 3.3l-1.5 1.5C4.6 15.5 4 13.8 4 12c0-4.4 3.6-8 8-8m0 4a4 4 0 0 0-4 4c0 1.2.5 2.3 1.4 3L11 13.4V10h2v3.4l1.6 1.6c.9-.7 1.4-1.8 1.4-3a4 4 0 0 0-4-4Z"/>',
473+
},
450474
},
451475
aliases: {
452476
'more-vert': {
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Icon } from '@iconify/react';
18+
import Box from '@mui/material/Box';
19+
import Link from '@mui/material/Link';
20+
import Typography from '@mui/material/Typography';
21+
import React from 'react';
22+
import { useTranslation } from '../../plugin/pluginI18n';
23+
24+
/**
25+
* Represents a parsed a8r.io annotation item with display metadata.
26+
*/
27+
export type A8RMetadataItem = {
28+
/** The annotation key without the 'a8r.io/' prefix (e.g., 'owner', 'description') */
29+
key: string;
30+
/** Translation key for the display label */
31+
labelKey: string;
32+
/** The annotation value */
33+
value: string;
34+
/** MDI icon identifier for visual display */
35+
icon: string;
36+
/** Whether the value is a valid HTTP/HTTPS URL */
37+
isLink: boolean;
38+
};
39+
40+
const A8R_ICON_MAP: Record<string, { icon: string; labelKey: string }> = {
41+
description: { icon: 'mdi:information-outline', labelKey: 'Description' },
42+
owner: { icon: 'mdi:account', labelKey: 'Owner' },
43+
dependencies: { icon: 'mdi:sitemap', labelKey: 'Dependencies' },
44+
chat: { icon: 'mdi:chat', labelKey: 'Chat' },
45+
bugs: { icon: 'mdi:bug', labelKey: 'Bugs' },
46+
logs: { icon: 'mdi:chart-timeline-variant', labelKey: 'Logs' },
47+
documentation: { icon: 'mdi:book-open-page-variant', labelKey: 'Documentation' },
48+
repository: { icon: 'mdi:github', labelKey: 'Repository' },
49+
support: { icon: 'mdi:help-circle', labelKey: 'Support' },
50+
runbook: { icon: 'mdi:script-text', labelKey: 'Runbook' },
51+
incidents: { icon: 'mdi:alert-circle', labelKey: 'Incidents' },
52+
uptime: { icon: 'mdi:check-circle', labelKey: 'Uptime' },
53+
performance: { icon: 'mdi:speedometer', labelKey: 'Performance' },
54+
};
55+
56+
const PREFERRED_ORDER = [
57+
'description',
58+
'owner',
59+
'dependencies',
60+
'chat',
61+
'documentation',
62+
'repository',
63+
'logs',
64+
'bugs',
65+
'support',
66+
'runbook',
67+
'incidents',
68+
'uptime',
69+
'performance',
70+
];
71+
72+
function isValidHttpUrl(value: string) {
73+
try {
74+
const url = new URL(value);
75+
return url.protocol === 'http:' || url.protocol === 'https:';
76+
} catch {
77+
return false;
78+
}
79+
}
80+
81+
/**
82+
* Parses Kubernetes service annotations and extracts a8r.io metadata items.
83+
*
84+
* Filters annotations starting with 'a8r.io/', maps them to display-friendly
85+
* metadata items with icons and labels, and sorts by preferred display order.
86+
*
87+
* @param annotations - Key-value pairs of Kubernetes annotations (defaults to empty object)
88+
* @returns Array of parsed metadata items sorted by preferred display order
89+
*/
90+
export function getA8RMetadata(annotations: Record<string, string> = {}): A8RMetadataItem[] {
91+
return Object.entries(annotations)
92+
.filter(([key]) => key.startsWith('a8r.io/'))
93+
.map(([key, value]) => {
94+
const shortKey = key.replace('a8r.io/', '');
95+
const meta = A8R_ICON_MAP[shortKey];
96+
if (!meta) return null;
97+
98+
return {
99+
key: shortKey,
100+
labelKey: meta.labelKey,
101+
value,
102+
icon: meta.icon,
103+
isLink: isValidHttpUrl(value),
104+
};
105+
})
106+
.filter((v): v is A8RMetadataItem => Boolean(v))
107+
.sort((a, b) => {
108+
const ai = PREFERRED_ORDER.indexOf(a.key);
109+
const bi = PREFERRED_ORDER.indexOf(b.key);
110+
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
111+
});
112+
}
113+
114+
export default function A8RServiceInfo({ annotations }: { annotations?: Record<string, string> }) {
115+
const { t } = useTranslation();
116+
117+
const metadata = React.useMemo(() => getA8RMetadata(annotations), [annotations]);
118+
119+
if (metadata.length === 0) return null;
120+
121+
return (
122+
<Box display="flex" flexDirection="column" gap={1.5}>
123+
{metadata.map(item => (
124+
<Box key={item.key} display="flex" alignItems="center">
125+
<Icon icon={item.icon} width="20" style={{ marginRight: 8 }} />
126+
<Typography variant="body2">
127+
<strong>{t(item.labelKey)}:</strong>{' '}
128+
{item.isLink ? (
129+
<Link href={item.value} target="_blank" rel="noopener noreferrer">
130+
{item.value}
131+
</Link>
132+
) : (
133+
item.value
134+
)}
135+
</Typography>
136+
</Box>
137+
))}
138+
</Box>
139+
);
140+
}

frontend/src/components/service/Details.tsx

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { DetailsGrid, MetadataDictGrid } from '../common/Resource';
3030
import PortForward from '../common/Resource/PortForward';
3131
import { SectionBox } from '../common/SectionBox';
3232
import SimpleTable from '../common/SimpleTable';
33+
import A8RServiceInfo from './A8RServiceInfo';
3334

3435
export default function ServiceDetails(props: {
3536
name?: string;
@@ -82,32 +83,47 @@ export default function ServiceDetails(props: {
8283
},
8384
]
8485
}
85-
extraSections={item =>
86-
item && [
86+
extraSections={item => {
87+
if (!item) {
88+
return [];
89+
}
90+
91+
const annotations = item.metadata?.annotations ?? {};
92+
const hasA8r = Object.keys(annotations).some(key => key.startsWith('a8r.io/'));
93+
94+
return [
95+
// Conditionally add Service Information
96+
...(hasA8r
97+
? [
98+
{
99+
id: 'headlamp.service-a8r-info',
100+
section: (
101+
<SectionBox title={t('Service Information')}>
102+
<A8RServiceInfo annotations={annotations} />
103+
</SectionBox>
104+
),
105+
},
106+
]
107+
: []),
108+
87109
{
88110
id: 'headlamp.service-ports',
89111
section: (
90112
<SectionBox title={t('Ports')}>
91113
<SimpleTable
92114
data={item.spec.ports}
93115
columns={[
94-
{
95-
label: t('Protocol'),
96-
datum: 'protocol',
97-
},
98-
{
99-
label: t('translation|Name'),
100-
datum: 'name',
101-
},
116+
{ label: t('Protocol'), datum: 'protocol' },
117+
{ label: t('translation|Name'), datum: 'name' },
102118
{
103119
label: t('Ports'),
104120
getter: ({ port, targetPort }) => (
105-
<React.Fragment>
121+
<>
106122
<ValueLabel>{port}</ValueLabel>
107123
<InlineIcon icon="mdi:chevron-right" />
108124
<ValueLabel>{targetPort}</ValueLabel>
109125
<PortForward containerPort={targetPort} resource={item} />
110-
</React.Fragment>
126+
</>
111127
),
112128
},
113129
]}
@@ -116,6 +132,7 @@ export default function ServiceDetails(props: {
116132
</SectionBox>
117133
),
118134
},
135+
119136
{
120137
id: 'headlamp.service-endpoints',
121138
section: (
@@ -134,8 +151,8 @@ export default function ServiceDetails(props: {
134151
label: t('translation|Addresses'),
135152
getter: endpoint => (
136153
<Box display="flex" flexDirection="column">
137-
{endpoint.getAddresses().map((address: string) => (
138-
<ValueLabel>{address}</ValueLabel>
154+
{endpoint.getAddresses().map((address: string, index: number) => (
155+
<ValueLabel key={index}>{address}</ValueLabel>
139156
))}
140157
</Box>
141158
),
@@ -147,6 +164,7 @@ export default function ServiceDetails(props: {
147164
</SectionBox>
148165
),
149166
},
167+
150168
{
151169
id: 'headlamp.service-endpointslices',
152170
section: (
@@ -165,15 +183,15 @@ export default function ServiceDetails(props: {
165183
label: t('translation|Addresses'),
166184
getter: endpointSlice => (
167185
<Box display="flex" flexDirection="column">
168-
{endpointSlice.spec.endpoints.map((endpoint: any) => (
169-
<ValueLabel>{endpoint.addresses.join(',')}</ValueLabel>
186+
{endpointSlice.spec.endpoints.map((ep: any, index: number) => (
187+
<ValueLabel key={index}>{ep.addresses.join(',')}</ValueLabel>
170188
))}
171189
</Box>
172190
),
173191
},
174192
{
175193
label: t('Ports'),
176-
getter: endpoint => endpoint.ports?.join(', '),
194+
getter: endpoint => endpoint.ports?.join(', ') ?? '',
177195
},
178196
{
179197
label: t('translation|Address Type'),
@@ -186,8 +204,8 @@ export default function ServiceDetails(props: {
186204
</SectionBox>
187205
),
188206
},
189-
]
190-
}
207+
];
208+
}}
191209
/>
192210
);
193211
}

frontend/src/components/service/List.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,44 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { Icon } from '@iconify/react';
18+
import { MenuItem } from '@mui/material';
19+
import ListItemIcon from '@mui/material/ListItemIcon';
20+
import ListItemText from '@mui/material/ListItemText';
21+
import { useMemo } from 'react';
1722
import { useTranslation } from 'react-i18next';
1823
import Service from '../../lib/k8s/service';
24+
import { useNamespaces } from '../../redux/filterSlice';
1925
import LabelListItem from '../common/LabelListItem';
2026
import { MetadataDictGrid } from '../common/Resource';
2127
import ResourceListView from '../common/Resource/ResourceListView';
28+
import { getA8RMetadata } from './A8RServiceInfo';
2229

2330
export default function ServiceList() {
2431
const { t } = useTranslation(['glossary', 'translation']);
32+
const { items } = Service.useList({ namespace: useNamespaces() });
33+
34+
const hideOwnerColumn = useMemo(() => {
35+
if (!items || items.length === 0) return true;
36+
const hasOwner = items.some(service => service?.metadata?.annotations?.['a8r.io/owner']);
37+
return !hasOwner;
38+
}, [items]);
2539

2640
return (
2741
<ResourceListView
2842
title={t('Services')}
2943
resourceClass={Service}
44+
hideColumns={hideOwnerColumn ? ['a8r-owner'] : []}
3045
columns={[
3146
'name',
3247
'namespace',
3348
'cluster',
49+
{
50+
id: 'a8r-owner',
51+
label: t('Owner'),
52+
gridTemplate: 'auto',
53+
getValue: service => service.metadata?.annotations?.['a8r.io/owner'] ?? '-',
54+
},
3455
{
3556
id: 'type',
3657
label: t('translation|Type'),
@@ -67,6 +88,36 @@ export default function ServiceList() {
6788
},
6889
'age',
6990
]}
91+
actions={[
92+
{
93+
id: 'a8r-actions',
94+
action: ({ item, closeMenu }) => {
95+
const annotations = item.metadata.annotations ?? {};
96+
const metadata = getA8RMetadata(annotations).filter(m => m.isLink);
97+
98+
if (metadata.length === 0) return null;
99+
100+
return (
101+
<>
102+
{metadata.map(meta => (
103+
<MenuItem
104+
key={meta.key}
105+
onClick={() => {
106+
window.open(meta.value, '_blank', 'noopener,noreferrer');
107+
closeMenu();
108+
}}
109+
>
110+
<ListItemIcon>
111+
<Icon icon={meta.icon} width="20" />
112+
</ListItemIcon>
113+
<ListItemText>{t(meta.labelKey)}</ListItemText>
114+
</MenuItem>
115+
))}
116+
</>
117+
);
118+
},
119+
},
120+
]}
70121
/>
71122
);
72123
}

0 commit comments

Comments
 (0)