Skip to content

How to use built in table with a custom plugin? #3510

Open
@sarg3nt

Description

@sarg3nt

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?

Example of what I've done.
Image

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

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Queued

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions