Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.finos.gitproxy.db;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.finos.gitproxy.db.model.Attestation;
import org.finos.gitproxy.db.model.PushQuery;
Expand Down Expand Up @@ -66,4 +68,64 @@ public interface PushStore {

/** Close resources. Called on shutdown. */
default void close() {}

/**
* Summarise push activity grouped by provider + project + repo_name. Returns one entry per distinct repo with total
* push count. Default implementation is correct but unoptimised; JDBC override uses a SQL aggregate.
*/
default List<RepoPushSummary> summarizeByRepo() {
record Key(String provider, String owner, String repoName) {}
Map<Key, Long> counts = new java.util.LinkedHashMap<>();
for (PushRecord r : find(PushQuery.builder().limit(Integer.MAX_VALUE).build())) {
Key k = new Key(
r.getProvider() != null ? r.getProvider() : "",
r.getProject() != null ? r.getProject() : "",
r.getRepoName() != null ? r.getRepoName() : "");
counts.merge(k, 1L, Long::sum);
}
return counts.entrySet().stream()
.map(e -> new RepoPushSummary(
e.getKey().provider(), e.getKey().owner(), e.getKey().repoName(), e.getValue()))
.toList();
}

/**
* Count push records for a user grouped by status. Accepts the same filters as {@link #find(PushQuery)} except
* {@code status} is ignored — counts are returned for all statuses. Default implementation is correct but
* unoptimised; JDBC override uses a SQL aggregate.
*/
default Map<String, Long> countByStatus(PushQuery query) {
PushQuery noStatus = PushQuery.builder()
.project(query.getProject())
.repoName(query.getRepoName())
.branch(query.getBranch())
.user(query.getUser())
.authorEmail(query.getAuthorEmail())
.commitTo(query.getCommitTo())
.search(query.getSearch())
.limit(Integer.MAX_VALUE)
.build();
Map<String, Long> result = new HashMap<>();
for (PushRecord r : find(noStatus)) {
result.merge(r.getStatus().name(), 1L, Long::sum);
}
return result;
}

/**
* Aggregate push status counts for all users in a single pass, returning a map of username → (status → count).
* Default implementation is correct but unoptimised; JDBC override uses a SQL aggregate.
*/
default Map<String, Map<String, Long>> countPushStatusByUser() {
Map<String, Map<String, Long>> result = new HashMap<>();
for (PushRecord r : find(PushQuery.builder().limit(Integer.MAX_VALUE).build())) {
if (r.getResolvedUser() == null) continue;
result.computeIfAbsent(r.getResolvedUser(), k -> new HashMap<>())
.merge(r.getStatus().name(), 1L, Long::sum);
}
return result;
}

/** Per-repo aggregate returned by {@link #summarizeByRepo()}. */
record RepoPushSummary(String provider, String owner, String repoName, long total) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
Expand All @@ -15,6 +16,7 @@
import org.finos.gitproxy.db.model.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.RowCallbackHandler;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
Expand Down Expand Up @@ -81,54 +83,73 @@ public Optional<PushRecord> findById(String id) {

@Override
public List<PushRecord> find(PushQuery query) {
StringBuilder sql = new StringBuilder("SELECT * FROM push_records WHERE 1=1");
MapSqlParameterSource params = new MapSqlParameterSource();

if (query.getStatus() != null) {
sql.append(" AND status = :status");
params.addValue("status", query.getStatus().name());
}
if (query.getProject() != null) {
sql.append(" AND project = :project");
params.addValue("project", query.getProject());
}
if (query.getRepoName() != null) {
sql.append(" AND repo_name = :repoName");
params.addValue("repoName", query.getRepoName());
}
if (query.getBranch() != null) {
sql.append(" AND branch = :branch");
params.addValue("branch", query.getBranch());
}
if (query.getCommitTo() != null) {
sql.append(" AND commit_to = :commitTo");
params.addValue("commitTo", query.getCommitTo());
}
if (query.getUser() != null) {
sql.append(" AND resolved_user = :user");
params.addValue("user", query.getUser());
}
if (query.getAuthorEmail() != null) {
sql.append(" AND author_email = :authorEmail");
params.addValue("authorEmail", query.getAuthorEmail());
}
if (query.getSearch() != null && !query.getSearch().isBlank()) {
sql.append(
" AND (LOWER(provider) LIKE :search OR LOWER(project) LIKE :search OR LOWER(repo_name) LIKE :search)");
params.addValue("search", "%" + query.getSearch().toLowerCase() + "%");
}

sql.append(" ORDER BY timestamp ");
sql.append(query.isNewestFirst() ? "DESC" : "ASC");
sql.append(" LIMIT :limit OFFSET :offset");
String where = buildWhere(query, params);
String sql = "SELECT * FROM push_records" + where
+ " ORDER BY timestamp " + (query.isNewestFirst() ? "DESC" : "ASC")
+ " LIMIT :limit OFFSET :offset";
params.addValue("limit", query.getLimit());
params.addValue("offset", query.getOffset());

List<PushRecord> results = jdbc.query(sql.toString(), params, PushRecordRowMapper.INSTANCE);
List<PushRecord> results = jdbc.query(sql, params, PushRecordRowMapper.INSTANCE);
results.forEach(this::hydrate);
return results;
}

@Override
public List<RepoPushSummary> summarizeByRepo() {
return jdbc.query(
"""
SELECT provider, project, repo_name, COUNT(*) AS total
FROM push_records
GROUP BY provider, project, repo_name
ORDER BY total DESC
""",
(rs, rowNum) -> new RepoPushSummary(
rs.getString("provider"),
rs.getString("project"),
rs.getString("repo_name"),
rs.getLong("total")));
}

@Override
public Map<String, Long> countByStatus(PushQuery query) {
MapSqlParameterSource params = new MapSqlParameterSource();
// Strip status so we get counts for all statuses
PushQuery noStatus = PushQuery.builder()
.project(query.getProject())
.repoName(query.getRepoName())
.branch(query.getBranch())
.user(query.getUser())
.authorEmail(query.getAuthorEmail())
.commitTo(query.getCommitTo())
.search(query.getSearch())
.build();
String where = buildWhere(noStatus, params);
Map<String, Long> result = new LinkedHashMap<>();
jdbc.query(
"SELECT status, COUNT(*) AS total FROM push_records" + where + " GROUP BY status",
params,
(RowCallbackHandler) rs -> result.put(rs.getString("status"), rs.getLong("total")));
return result;
}

@Override
public Map<String, Map<String, Long>> countPushStatusByUser() {
Map<String, Map<String, Long>> result = new LinkedHashMap<>();
jdbc.query("""
SELECT resolved_user, status, COUNT(*) AS total
FROM push_records
WHERE resolved_user IS NOT NULL
GROUP BY resolved_user, status
""", rs -> {
String user = rs.getString("resolved_user");
String status = rs.getString("status");
result.computeIfAbsent(user, k -> new LinkedHashMap<>()).put(status, rs.getLong("total"));
});
return result;
}

@Override
public void delete(String id) {
// CASCADE handles child tables
Expand Down Expand Up @@ -179,6 +200,50 @@ public void close() {

// --- Private helpers ---

/**
* Builds a SQL WHERE clause from the query and populates {@code params}. Returns the clause string starting with
* {@code " WHERE 1=1 ..."} (or just {@code " WHERE 1=1"} if no filters are set).
*/
private static String buildWhere(PushQuery query, MapSqlParameterSource params) {
StringBuilder sql = new StringBuilder(" WHERE 1=1");

if (query.getStatus() != null) {
sql.append(" AND status = :status");
params.addValue("status", query.getStatus().name());
}
if (query.getProject() != null) {
sql.append(" AND project = :project");
params.addValue("project", query.getProject());
}
if (query.getRepoName() != null) {
sql.append(" AND repo_name = :repoName");
params.addValue("repoName", query.getRepoName());
}
if (query.getBranch() != null) {
sql.append(" AND branch = :branch");
params.addValue("branch", query.getBranch());
}
if (query.getCommitTo() != null) {
sql.append(" AND commit_to = :commitTo");
params.addValue("commitTo", query.getCommitTo());
}
if (query.getUser() != null) {
sql.append(" AND resolved_user = :user");
params.addValue("user", query.getUser());
}
if (query.getAuthorEmail() != null) {
sql.append(" AND author_email = :authorEmail");
params.addValue("authorEmail", query.getAuthorEmail());
}
if (query.getSearch() != null && !query.getSearch().isBlank()) {
sql.append(
" AND (LOWER(provider) LIKE :search OR LOWER(project) LIKE :search OR LOWER(repo_name) LIKE :search)");
params.addValue("search", "%" + query.getSearch().toLowerCase() + "%");
}

return sql.toString();
}

private PushRecord updateStatus(String id, PushStatus status, Attestation attestation) {
tx.executeWithoutResult(txStatus -> {
int updated = jdbc.update(
Expand Down
6 changes: 6 additions & 0 deletions git-proxy-java-dashboard/frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export async function fetchPushes(params: URLSearchParams) {
return res.json()
}

export async function fetchPushCounts(params: URLSearchParams): Promise<Record<string, number>> {
const res = await apiFetch('/api/push/counts?' + params)
if (!res.ok) throw new Error('Failed to fetch push counts')
return res.json()
}

export async function fetchPush(id: string) {
const url = id.includes('_') ? `/api/push/by-ref/${id}` : `/api/push/${id}`
const res = await apiFetch(url)
Expand Down
34 changes: 19 additions & 15 deletions git-proxy-java-dashboard/frontend/src/pages/PushList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { approvePush, fetchPushes, rejectPush } from '../api'
import { approvePush, fetchPushCounts, fetchPushes, rejectPush } from '../api'
import { StatusBadge } from '../components/StatusBadge'
import type { CurrentUser, PushRecord, PushStatus } from '../types'

Expand Down Expand Up @@ -142,17 +142,16 @@ export function PushList({ currentUser }: PushListProps) {
[currentUser],
)

const loadCounts = useCallback(async () => {
const results: Partial<Record<string, number>> = {}
await Promise.all(
STATUSES.map(async (s) => {
const params = new URLSearchParams({ status: s, limit: '1000' })
const data: PushRecord[] = await fetchPushes(params)
results[s] = data.length
}),
)
setCounts(results)
}, [])
const loadCounts = useCallback(
async (search: string, myOnly: boolean) => {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (myOnly && currentUser?.username) params.set('user', currentUser.username)
const data = await fetchPushCounts(params)
setCounts(data)
},
[currentUser],
)

// Clear selection when leaving PENDING filter
useEffect(() => {
Expand All @@ -169,10 +168,15 @@ export function PushList({ currentUser }: PushListProps) {
return () => clearInterval(timer)
}, [filterStatus, filterRepo, myPushesOnly, newestFirst, page, load])

// Load counts once on mount
// Reload counts when filter-relevant state changes, with auto-refresh
useEffect(() => {
void Promise.resolve().then(() => loadCounts())
}, [loadCounts])
void Promise.resolve().then(() => loadCounts(filterRepo, myPushesOnly))
const timer = setInterval(
() => loadCounts(filterRepo, myPushesOnly),
10_000,
)
return () => clearInterval(timer)
}, [filterRepo, myPushesOnly, loadCounts])

function handleRepoChange(value: string) {
setFilterRepo(value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ public List<PushRecord> list(
return pushStore.find(query.build());
}

/**
* Count push records grouped by status. Accepts the same filter params as {@link #list} except {@code status} —
* returns counts for all statuses so the caller can populate all filter tabs in one round trip.
*/
@Operation(operationId = "countPushes", summary = "Count push records by status")
@GetMapping("/counts")
public Map<String, Long> counts(
@RequestParam(required = false) String project,
@RequestParam(required = false) String repo,
@RequestParam(required = false) String user,
@RequestParam(required = false) String search) {

PushQuery.PushQueryBuilder query = PushQuery.builder();
if (project != null && !project.isBlank()) query.project(project);
if (repo != null && !repo.isBlank()) query.repoName(repo);
if (user != null && !user.isBlank()) query.user(user);
if (search != null && !search.isBlank()) query.search(search);

return pushStore.countByStatus(query.build());
}

/**
* Look up a push record by its commit reference ({commitFrom}_{commitTo}). Used by the transparent proxy flow where
* we link to a push before it has been saved with a UUID.
Expand Down
Loading
Loading