feat(clients): adds group affiliation to clients table
Build & Deploy / build (push) Successful in 2m18s

This commit is contained in:
2026-07-17 19:47:32 +02:00
parent 6b18cbca0e
commit a01bbd368a
4 changed files with 93 additions and 4 deletions
+3 -1
View File
@@ -335,7 +335,9 @@ internal static class AdminEndpoints
models = BuildModelSummaries(hub, store), models = BuildModelSummaries(hub, store),
apiKeys = store.ListApiKeys(), apiKeys = store.ListApiKeys(),
groups = store.ListGroups(), groups = store.ListGroups(),
apiKeyGroups = store.ListApiKeyGroups() apiKeyGroups = store.ListApiKeyGroups(),
clientGroups = store.ResolveClientGroups(
hub.ClientSnapshots.Select(c => c.Id).ToList())
}; };
private static IReadOnlyList<ClientSummary> BuildClientSummaries(TunnelHub hub, ManagementStore store) private static IReadOnlyList<ClientSummary> BuildClientSummaries(TunnelHub hub, ManagementStore store)
@@ -934,6 +934,73 @@ internal sealed class ManagementStore
} }
} }
public IReadOnlyDictionary<string, IReadOnlyList<string>> ResolveClientGroups(IReadOnlyList<string> clientIds)
{
if (!_isAvailable || clientIds.Count == 0)
{
return new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
}
var result = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var clientId in clientIds)
{
result[clientId] = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
}
lock (_lock)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT g.name, gm.client_id, gm.client_pattern
FROM group_members gm
INNER JOIN groups g ON gm.group_id = g.id
""";
using var reader = command.ExecuteReader();
while (reader.Read())
{
var groupName = reader.GetString(0);
var explicitClientId = reader.IsDBNull(1) ? null : reader.GetString(1);
var pattern = reader.IsDBNull(2) ? null : reader.GetString(2);
if (!string.IsNullOrWhiteSpace(explicitClientId)
&& result.TryGetValue(explicitClientId, out var explicitGroups))
{
explicitGroups.Add(groupName);
}
else if (!string.IsNullOrWhiteSpace(pattern))
{
Regex? regex = null;
try
{
regex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
}
catch (RegexParseException)
{
continue;
}
foreach (var clientId in clientIds)
{
if (regex.IsMatch(clientId) && result.TryGetValue(clientId, out var groups))
{
groups.Add(groupName);
}
}
}
}
}
var frozen = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var (clientId, groups) in result)
{
frozen[clientId] = groups.ToList();
}
return frozen;
}
private IReadOnlyList<string> GetApiKeyGroupIdsLocked(string apiKeyId) private IReadOnlyList<string> GetApiKeyGroupIdsLocked(string apiKeyId)
{ {
using var connection = OpenConnection(); using var connection = OpenConnection();
+12 -1
View File
@@ -281,7 +281,7 @@ textarea {
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
min-width: 760px; min-width: 900px;
} }
th, th,
@@ -359,6 +359,17 @@ tr:last-child td {
color: var(--amber); color: var(--amber);
} }
.badge[href] {
text-decoration: none;
cursor: pointer;
}
.badge[href]:hover {
border-color: var(--blue);
background: #edf4ff;
color: var(--blue);
}
.metric-grid { .metric-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr)); grid-template-columns: repeat(4, minmax(120px, 1fr));
+11 -2
View File
@@ -315,7 +315,10 @@ function renderClients() {
} }
function clientsTable(clients) { function clientsTable(clients) {
const rows = clients.map((client) => ` const clientGroups = state.summary?.clientGroups || {};
const rows = clients.map((client) => {
const groups = clientGroups[client.id] || [];
return `
<tr> <tr>
<td> <td>
<div class="cell-main">${escapeHtml(client.id)}</div> <div class="cell-main">${escapeHtml(client.id)}</div>
@@ -335,6 +338,11 @@ function clientsTable(clients) {
</td> </td>
<td>${modelBadges(client.models)}</td> <td>${modelBadges(client.models)}</td>
<td>${modelBadges(client.activeModels)}</td> <td>${modelBadges(client.activeModels)}</td>
<td>
<div class="badge-row">
${groups.length ? groups.map((g) => `<a class="badge" href="#groups/${encodeURIComponent(g)}">${escapeHtml(g)}</a>`).join("") : `<span class="cell-sub">None</span>`}
</div>
</td>
<td> <td>
<div class="actions"> <div class="actions">
<button class="button warning" data-action="disable-hour" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable 1h</button> <button class="button warning" data-action="disable-hour" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable 1h</button>
@@ -343,7 +351,7 @@ function clientsTable(clients) {
</div> </div>
</td> </td>
</tr> </tr>
`).join(""); `}).join("");
return ` return `
<table> <table>
@@ -355,6 +363,7 @@ function clientsTable(clients) {
<th>Requests</th> <th>Requests</th>
<th>Listed models</th> <th>Listed models</th>
<th>Active models</th> <th>Active models</th>
<th>Groups</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>