Open
Description
We've created several plugins for Headlamp, one of which dynamically creates Kube Configs for users using service accounts that logs them into Azure Entra ID and then tracks them. We then have another plugin that allows us to view everyone the other plugin has created Kube Configs for and when they are being refreshed.
I'd like to use the built-in table that the rest of Headlamp uses, but after several hours have not been able to get it to work, so I've hacked together my own using custom CSS, but it's not perfect and not ideal.
I've combed through the example plugins and the code of the project and can't figure it out.
Is this possible, if so, can someone get me some sample code?
import { registerRoute, registerSidebarEntry } from '@kinvolk/headlamp-plugin/lib';
import React, { useEffect, useState } from 'react';
// Get the base URL from the current location
function getClusterBaseUrl() {
// e.g. https://k8s.vault.ad.selinc.com/c/main or /c/other
const { protocol, host } = window.location;
return protocol + '//' + host + '/';
}
const USERS_API = getClusterBaseUrl().replace(/\/$/, '') + '/kube_config/users';
function UsersList() {
const [users, setUsers] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sortKey, setSortKey] = useState<string>('userid');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [filter, setFilter] = useState<string>('');
useEffect(() => {
setLoading(true);
fetch(USERS_API)
.then(async res => {
if (!res.ok) throw new Error(await res.text());
const text = await res.text();
console.log('USERS_API response:', text);
try {
return JSON.parse(text);
} catch (e) {
throw new Error('Invalid JSON: ' + text);
}
})
.then(setUsers)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
// Compute filtered and sorted users
const filteredSortedUsers = Object.entries(users)
.filter(([userid, info]: [string, any]) => {
if (!filter) return true;
const f = filter.toLowerCase();
return (
userid.toLowerCase().includes(f) ||
(info.roles || []).join(',').toLowerCase().includes(f) ||
(info.active ? 'active' : 'inactive').includes(f) ||
(info['creation-date'] || '').toLowerCase().includes(f) ||
(info['last_seen'] || '').toLowerCase().includes(f) ||
(Array.isArray(info.groups) ? info.groups.join(',').toLowerCase().includes(f) : false)
);
})
.sort((a, b) => {
if (!sortKey) return 0;
const [aKey, aInfo] = a;
const [bKey, bInfo] = b;
let aVal = aInfo[sortKey];
let bVal = bInfo[sortKey];
if (sortKey === 'userid') {
aVal = aKey;
bVal = bKey;
}
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
}
return 0;
});
if (loading) return <div>Loading users...</div>;
if (error) return <div style={{ color: 'red' }}>Error: {error}</div>;
if (!users || Object.keys(users).length === 0) return <div>No users found.</div>;
return (
<div style={{ padding: 24 }}>
<h2>Provisioned Users</h2>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, alignItems: 'center' }}>
<input
type="text"
placeholder="Filter users..."
value={filter}
onChange={e => setFilter(e.target.value)}
style={{
padding: 6,
borderRadius: 4,
border: '1px solid #434241',
background: '#333333',
color: '#e0e0e0',
}}
/>
<button
onClick={() => {
setLoading(true);
fetch(USERS_API)
.then(async res => {
if (!res.ok) throw new Error(await res.text());
const text = await res.text();
try {
return JSON.parse(text);
} catch (e) {
throw new Error('Invalid JSON: ' + text);
}
})
.then(setUsers)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}}
title="Refresh"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
display: 'flex',
alignItems: 'center',
}}
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.93 4.93A7 7 0 1 1 3 10"
stroke="#90caf9"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 4v6h6"
stroke="#90caf9"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<style>{`
.users-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background: #292827;
font-family: Roboto, Arial, sans-serif;
font-size: 14px;
color: #e0e0e0;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.users-table th, .users-table td {
padding: 10px 16px;
border-bottom: 1px solid #434241;
text-align: left;
}
.users-table th {
background: #333333;
font-weight: 500;
letter-spacing: 0.03em;
color: #e0e0e0;
border-top: 1px solid #434241;
}
.users-table tr:last-child td {
border-bottom: none;
}
.users-table tbody tr {
transition: background 0.15s;
}
.users-table tbody tr:hover {
background: #292827;
}
.users-table details summary {
cursor: pointer;
font-size: 13px;
color: #90caf9;
}
.users-table ul {
margin: 0;
padding-left: 18px;
}
`}</style>
<table className="users-table">
<thead>
<tr>
<th
style={{ cursor: 'pointer', borderBottom: '1px solid #434241' }}
onClick={() => {
if (sortKey === 'userid') {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortKey('userid');
setSortDir('asc');
}
}}
>
{'User ID'}{' '}
<span style={{ color: '#90caf9', fontWeight: 700 }}>
{sortKey === 'userid' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</span>
</th>
<th
style={{ cursor: 'pointer', borderBottom: '1px solid #434241' }}
onClick={() => {
if (sortKey === 'roles') {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortKey('roles');
setSortDir('asc');
}
}}
>
{'Roles'} {sortKey === 'roles' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th
style={{ cursor: 'pointer', borderBottom: '1px solid #434241' }}
onClick={() => {
if (sortKey === 'active') {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortKey('active');
setSortDir('asc');
}
}}
>
{'Status'} {sortKey === 'active' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th
style={{ cursor: 'pointer', borderBottom: '1px solid #434241' }}
onClick={() => {
if (sortKey === 'creation-date') {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortKey('creation-date');
setSortDir('asc');
}
}}
>
{'Created'} {sortKey === 'creation-date' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th
style={{ cursor: 'pointer', borderBottom: '1px solid #434241' }}
onClick={() => {
if (sortKey === 'last_seen') {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortKey('last_seen');
setSortDir('asc');
}
}}
>
{'Last Seen'} {sortKey === 'last_seen' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th style={{ borderBottom: '1px solid #434241' }}>{'Groups'}</th>
<th style={{ borderBottom: '1px solid #434241' }}>{'Audit Log'}</th>
</tr>
</thead>
<tbody>
{filteredSortedUsers.map(([userid, info]: [string, any]) => (
<tr key={userid} style={{ borderBottom: '1px solid #434241' }}>
<td style={{ wordBreak: 'break-all' }}>{userid}</td>
<td>{(info.roles || []).join(', ')}</td>
<td>{info.active ? 'Active' : 'Inactive'}</td>
<td>
{info['creation-date'] ? new Date(info['creation-date']).toLocaleString() : ''}
</td>
<td>{info['last_seen'] ? new Date(info['last_seen']).toLocaleString() : ''}</td>
<td>
{Array.isArray(info.groups) && info.groups.length > 0 ? (
<details>
<summary>{info.groups.length} group(s)</summary>
<ul style={{ maxHeight: 120, overflowY: 'auto', margin: 0, paddingLeft: 16 }}>
{info.groups.map((g: string, i: number) => (
<li key={i} style={{ fontSize: 12 }}>
{g}
</li>
))}
</ul>
</details>
) : (
'—'
)}
</td>
<td>
{Array.isArray(info.audit_log) && info.audit_log.length > 0 ? (
<details>
<summary>Show audit log</summary>
<ul style={{ maxHeight: 120, overflowY: 'auto', margin: 0, paddingLeft: 16 }}>
{info.audit_log.map((entry: any, i: number) => {
let localTime = '';
if (entry.timestamp) {
const d = new Date(entry.timestamp);
if (!isNaN(d.getTime())) {
localTime = d.toLocaleString();
} else {
localTime = entry.timestamp;
}
}
return (
<li key={i} style={{ fontSize: 12 }}>
<strong>{entry.event}</strong>
{localTime ? ' (' + localTime + ')' : ''}
<br />
<span style={{ color: '#aaa' }}>{entry.reason}</span>
</li>
);
})}
</ul>
</details>
) : (
'—'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
registerSidebarEntry({
parent: 'security',
name: 'users',
label: 'Users',
url: '/security-users',
component: UsersList,
});
// Register the actual route so the page is served
registerRoute({
path: '/security-users',
sidebar: 'users',
name: 'users',
exact: true,
component: UsersList,
});
Metadata
Metadata
Assignees
Labels
No labels
Type
Projects
Status
Queued