Skip to content

Commit 568107c

Browse files
committed
frontend: display a8r.io service metadata in service views
1 parent d9a3326 commit 568107c

File tree

3 files changed

+262
-18
lines changed

3 files changed

+262
-18
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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 Typography from '@mui/material/Typography';
20+
import _ from 'lodash';
21+
import Link from '../common/Link';
22+
23+
const A8R_ICON_MAP: Record<string, string> = {
24+
description: 'mdi:information-outline',
25+
owner: 'mdi:account',
26+
chat: 'mdi:chat',
27+
bugs: 'mdi:bug',
28+
logs: 'mdi:chart-timeline-variant',
29+
documentation: 'mdi:book-open-page-variant',
30+
repository: 'mdi:github',
31+
support: 'mdi:help-circle',
32+
runbook: 'mdi:script-text',
33+
incidents: 'mdi:alert-circle',
34+
uptime: 'mdi:check-circle',
35+
performance: 'mdi:speedometer',
36+
dependencies: 'mdi:sitemap',
37+
};
38+
39+
const LINKABLE_KEYS = [
40+
'chat',
41+
'bugs',
42+
'logs',
43+
'documentation',
44+
'repository',
45+
'support',
46+
'runbook',
47+
'incidents',
48+
'uptime',
49+
'performance',
50+
];
51+
function isValidHttpUrl(value: string) {
52+
try {
53+
const url = new URL(value);
54+
return url.protocol === 'http:' || url.protocol === 'https:';
55+
} catch {
56+
return false;
57+
}
58+
}
59+
60+
export default function A8RServiceInfo({ annotations }: { annotations?: Record<string, string> }) {
61+
//Defining the order of annotations for user friendly display
62+
const PREFERRED_ORDER = [
63+
'description',
64+
'owner',
65+
'dependencies',
66+
'chat',
67+
'documentation',
68+
'repository',
69+
'logs',
70+
'bugs',
71+
'support',
72+
'runbook',
73+
'incidents',
74+
'uptime',
75+
'performance',
76+
];
77+
const a8rEntries = Object.entries(annotations || {})
78+
.filter(([key]) => key.startsWith('a8r.io/'))
79+
.map(([key, value]) => ({
80+
type: key.replace('a8r.io/', ''),
81+
value,
82+
originalKey: key,
83+
}));
84+
85+
const sortedEntries = _.sortBy(a8rEntries, entry => {
86+
const index = PREFERRED_ORDER.indexOf(entry.type);
87+
return index === -1 ? 999 : index;
88+
});
89+
90+
if (sortedEntries.length === 0) return null;
91+
return (
92+
<Box display="flex" flexDirection="column" gap={1.5} py={1}>
93+
{sortedEntries.map(({ type, value, originalKey }) => {
94+
const icon = A8R_ICON_MAP[type] || 'mdi:tag-outline';
95+
const isLink = LINKABLE_KEYS.includes(type) && isValidHttpUrl(value);
96+
const label = _.capitalize(type);
97+
const renderedValue =
98+
type === 'dependencies'
99+
? value
100+
.split(',')
101+
.map(s => s.trim())
102+
.join(', ')
103+
: value;
104+
105+
return (
106+
<Box key={originalKey} display="flex" alignItems="center">
107+
<Icon icon={icon} width="20" style={{ marginRight: 8, color: 'text.secondary' }} />
108+
<Typography variant="body2">
109+
<strong>{label}:</strong>{' '}
110+
{isLink ? (
111+
<Link href={value} target="_blank" rel="noopener">
112+
{value}
113+
</Link>
114+
) : (
115+
renderedValue
116+
)}
117+
</Typography>
118+
</Box>
119+
);
120+
})}
121+
</Box>
122+
);
123+
}

frontend/src/components/service/Details.tsx

Lines changed: 38 additions & 18 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: (
@@ -135,7 +152,7 @@ export default function ServiceDetails(props: {
135152
getter: endpoint => (
136153
<Box display="flex" flexDirection="column">
137154
{endpoint.getAddresses().map((address: string) => (
138-
<ValueLabel>{address}</ValueLabel>
155+
<ValueLabel key={address}>{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,17 @@ 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) => (
187+
<ValueLabel key={ep.addresses.join(',')}>
188+
{ep.addresses.join(',')}
189+
</ValueLabel>
170190
))}
171191
</Box>
172192
),
173193
},
174194
{
175195
label: t('Ports'),
176-
getter: endpoint => endpoint.ports?.join(', '),
196+
getter: endpoint => endpoint.ports?.join(', ') ?? '',
177197
},
178198
{
179199
label: t('translation|Address Type'),
@@ -186,8 +206,8 @@ export default function ServiceDetails(props: {
186206
</SectionBox>
187207
),
188208
},
189-
]
190-
}
209+
];
210+
}}
191211
/>
192212
);
193213
}

frontend/src/components/service/List.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,24 @@
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';
1721
import { useTranslation } from 'react-i18next';
1822
import Service from '../../lib/k8s/service';
1923
import LabelListItem from '../common/LabelListItem';
2024
import { MetadataDictGrid } from '../common/Resource';
2125
import ResourceListView from '../common/Resource/ResourceListView';
2226

27+
function isValidHttpUrl(value: string) {
28+
try {
29+
const url = new URL(value);
30+
return url.protocol === 'http:' || url.protocol === 'https:';
31+
} catch {
32+
return false;
33+
}
34+
}
2335
export default function ServiceList() {
2436
const { t } = useTranslation(['glossary', 'translation']);
2537

@@ -31,6 +43,13 @@ export default function ServiceList() {
3143
'name',
3244
'namespace',
3345
'cluster',
46+
{
47+
id: 'a8r-owner',
48+
label: t('Owner'),
49+
gridTemplate: 'auto',
50+
getValue: service => service.metadata.annotations?.['a8r.io/owner'] || '-',
51+
render: service => service.metadata.annotations?.['a8r.io/owner'] || '-',
52+
},
3453
{
3554
id: 'type',
3655
label: t('translation|Type'),
@@ -67,6 +86,88 @@ export default function ServiceList() {
6786
},
6887
'age',
6988
]}
89+
actions={[
90+
{
91+
id: 'a8r-logs',
92+
action: ({ item, closeMenu }) => {
93+
const url = item.metadata.annotations?.['a8r.io/logs'];
94+
if (!url || !isValidHttpUrl(url)) return null;
95+
return (
96+
<MenuItem
97+
onClick={() => {
98+
window.open(url, '_blank', 'noopener,noreferrer');
99+
closeMenu();
100+
}}
101+
>
102+
<ListItemIcon>
103+
<Icon icon="mdi:chart-timeline-variant" width="20" />
104+
</ListItemIcon>
105+
<ListItemText>{t('View Logs')}</ListItemText>
106+
</MenuItem>
107+
);
108+
},
109+
},
110+
{
111+
id: 'a8r-docs',
112+
action: ({ item, closeMenu }) => {
113+
const url = item.metadata.annotations?.['a8r.io/documentation'];
114+
if (!url || !isValidHttpUrl(url)) return null;
115+
return (
116+
<MenuItem
117+
onClick={() => {
118+
window.open(url, '_blank', 'noopener,noreferrer');
119+
closeMenu();
120+
}}
121+
>
122+
<ListItemIcon>
123+
<Icon icon="mdi:book-open-page-variant" width="20" />
124+
</ListItemIcon>
125+
<ListItemText>{t('View Documentation')}</ListItemText>
126+
</MenuItem>
127+
);
128+
},
129+
},
130+
{
131+
id: 'a8r-repo',
132+
action: ({ item, closeMenu }) => {
133+
const url = item.metadata.annotations?.['a8r.io/repository'];
134+
if (!url || !isValidHttpUrl(url)) return null;
135+
return (
136+
<MenuItem
137+
onClick={() => {
138+
window.open(url, '_blank', 'noopener,noreferrer');
139+
closeMenu();
140+
}}
141+
>
142+
<ListItemIcon>
143+
<Icon icon="mdi:github" width="20" />
144+
</ListItemIcon>
145+
<ListItemText>{t('View Repository')}</ListItemText>
146+
</MenuItem>
147+
);
148+
},
149+
},
150+
{
151+
id: 'a8r-chat',
152+
action: ({ item, closeMenu }) => {
153+
const url = item.metadata.annotations?.['a8r.io/chat'];
154+
if (!url || !isValidHttpUrl(url)) return null;
155+
return (
156+
<MenuItem
157+
onClick={() => {
158+
window.open(url, '_blank', 'noopener,noreferrer');
159+
closeMenu();
160+
}}
161+
>
162+
<ListItemIcon>
163+
<Icon icon="mdi:chat" width="20" />
164+
</ListItemIcon>
165+
<ListItemText>{t('Contact Team')}</ListItemText>
166+
</MenuItem>
167+
);
168+
},
169+
},
170+
]}
70171
/>
71172
);
72173
}

0 commit comments

Comments
 (0)