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
1 change: 1 addition & 0 deletions changelog.d/added-10613.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- dashboard: Review Queue collapsed row shows a priority-split history sparkline (#10613)
1 change: 1 addition & 0 deletions changelog.d/fixed-10613-review-queue-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed the review queue collapsed summary escape helper used by visual summary tests.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Changed the Review Queue collapsed row to show priority-split history with shared sparkline range controls (#10610)
67 changes: 60 additions & 7 deletions src/pkg/dashboard/api_review_queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"

ghpkg "github.com/hivecommons/hive/pkg/github"
Expand All @@ -20,15 +21,32 @@ const (
reviewQueueMaxLimit = 200
)

type reviewQueueHistoryStore struct {
mu sync.Mutex
entries []ReviewQueueHistoryEntry
}

var reviewQueueHistoryByServer sync.Map

// ReviewQueueHistoryEntry is one priority-split review queue depth sample.
type ReviewQueueHistoryEntry struct {
Timestamp int64 `json:"t"`
Total int `json:"total"`
High int `json:"high"`
Normal int `json:"normal"`
Low int `json:"low"`
}

// reviewQueueResponse is one page of the PR review queue.
type reviewQueueResponse struct {
GeneratedAt time.Time `json:"generated_at"`
SnapshotAt time.Time `json:"snapshot_at,omitzero"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"has_more"`
Items []ghpkg.ReviewQueueEntry `json:"items"`
GeneratedAt time.Time `json:"generated_at"`
SnapshotAt time.Time `json:"snapshot_at,omitzero"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"has_more"`
History []ReviewQueueHistoryEntry `json:"history"`
Items []ghpkg.ReviewQueueEntry `json:"items"`
}

// handleReviewQueue serves GET /api/review/queue?limit=N&offset=M: every open
Expand Down Expand Up @@ -65,13 +83,15 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
}
actionable := s.lastActionableForPRModels()
queue := ghpkg.ReviewQueueFromActionable(actionable, opts)
history := s.recordReviewQueueHistory(queue, opts.Now)

resp := reviewQueueResponse{
GeneratedAt: opts.Now.UTC(),
SnapshotAt: actionable.GeneratedAt,
Total: len(queue),
Limit: limit,
Offset: offset,
History: history,
Items: []ghpkg.ReviewQueueEntry{},
}
if offset < len(queue) {
Expand Down Expand Up @@ -109,3 +129,36 @@ func reviewQueuePaging(r *http.Request) (limit, offset int, err error) {
}
return limit, offset, nil
}

func (s *Server) recordReviewQueueHistory(queue []ghpkg.ReviewQueueEntry, now time.Time) []ReviewQueueHistoryEntry {
if s == nil {
return nil
}
entry := ReviewQueueHistoryEntry{Timestamp: now.UTC().UnixMilli(), Total: len(queue)}
for _, item := range queue {
switch item.Priority {
case ghpkg.ReviewPriorityHigh:
entry.High++
case ghpkg.ReviewPriorityLow:
entry.Low++
default:
entry.Normal++
}
}

storeAny, _ := reviewQueueHistoryByServer.LoadOrStore(s, &reviewQueueHistoryStore{})
store := storeAny.(*reviewQueueHistoryStore)
store.mu.Lock()
defer store.mu.Unlock()
if n := len(store.entries); n > 0 && entry.Timestamp-store.entries[n-1].Timestamp < trendHistoryMinIntervalMs {
store.entries[n-1] = entry
} else {
store.entries = append(store.entries, entry)
}
if len(store.entries) > trendHistoryMaxEntries {
store.entries = store.entries[len(store.entries)-trendHistoryMaxEntries:]
}
out := make([]ReviewQueueHistoryEntry, len(store.entries))
copy(out, store.entries)
return out
}
15 changes: 15 additions & 0 deletions src/pkg/dashboard/api_review_queue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ func TestHandleReviewQueue_RanksAndPages(t *testing.T) {
}
}

func TestHandleReviewQueue_IncludesPriorityHistory(t *testing.T) {
s := reviewQueueTestServer(t, filepath.Join(t.TempDir(), "missing.json"))
resp := decodeReviewQueue(t, doOwnerGet(s, "/api/review/queue"))
if len(resp.History) != 1 {
t.Fatalf("history len = %d, want 1", len(resp.History))
}
entry := resp.History[0]
if entry.Total != 3 || entry.High != 1 || entry.Normal != 1 || entry.Low != 1 {
t.Fatalf("history priority split = %+v, want total/high/normal/low 3/1/1/1", entry)
}
if entry.Timestamp <= 0 {
t.Fatalf("history timestamp = %d, want unix millis", entry.Timestamp)
}
}

func TestHandleReviewQueue_RejectsBadPaging(t *testing.T) {
s := reviewQueueTestServer(t, filepath.Join(t.TempDir(), "missing.json"))
for _, q := range []string{"limit=0", "limit=-1", "limit=abc", "limit=201", "offset=-1", "offset=x"} {
Expand Down
19 changes: 19 additions & 0 deletions src/pkg/dashboard/review_queue_view_9607_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ func TestReviewQueueViewStaticWiring(t *testing.T) {
t.Errorf("index.html is missing review-queue wiring %q", want)
}
}
// The collapsed Review Queue card must use the shared range-control and
// renderSparkline path, not the old count-only gauge.
for _, want := range []string{
`function reviewQueueCollapsedSummary(text)`,
`function reviewQueueRangeControls()`,
`overview-kpi-range-btn`,
`data-action="setReviewQueueWindow"`,
`function reviewQueueSparkline(entries)`,
`renderSparkline(null, values`,
`['high', high, 'High priority']`,
} {
if !strings.Contains(html, want) {
t.Errorf("index.html is missing review-queue collapsed sparkline wiring %q", want)
}
}
if strings.Contains(html, `sectionId === 'review-queue-section') {
inner = miniGauge`) {
t.Error("Review Queue collapsed summary still uses the gauge renderer")
}
// The view must never fall back to a browser-native dialog.
for _, banned := range []string{"window.alert", "window.confirm", "window.prompt"} {
if strings.Contains(jsFunc(t, html, "renderReviewQueue"), banned) {
Expand Down
116 changes: 115 additions & 1 deletion src/pkg/dashboard/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3819,6 +3819,24 @@
.review-queue-reasons { margin: var(--sp-2) 0 0; padding-left: var(--sp-6); color: var(--muted); font-size: var(--fs-xs); }
.review-queue-noreasons { color: var(--muted); font-size: var(--fs-xs); margin-top: var(--sp-2); }
.review-queue-empty { color: var(--terminal-muted); font-size: var(--fs-base); padding: var(--sp-4); }
.review-queue-collapsed { width: min(460px, 44vw); max-width: 460px; gap: var(--sp-3); }
.review-queue-collapsed-main { display: inline-flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.review-queue-collapsed-total { color: var(--text); font-weight: 900; font-variant-numeric: tabular-nums; }
.review-queue-chip { display: inline-flex; align-items: center; gap: var(--sp-1); padding: var(--sp-0) var(--sp-2); border: 1px solid var(--blue); border-radius: var(--r-pill); color: var(--muted); font-size: var(--fs-2xs); font-weight: 800; font-variant-numeric: tabular-nums; }
.review-queue-chip.high { border-color: var(--red); }
.review-queue-chip.low { border-color: var(--muted); }
.review-queue-delta { font-size: var(--fs-2xs); font-weight: 900; font-variant-numeric: tabular-nums; }
.review-queue-delta.up { color: var(--red); }
.review-queue-delta.down { color: var(--green); }
.review-queue-delta.flat { color: var(--muted); }
.review-queue-spark { flex: 1 1 120px; min-width: 96px; }
.review-queue-spark .sparkline { width: 100%; }
.review-queue-spark svg { width: 100%; height: 24px; }
.review-queue-priority-line { fill: none; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; opacity: 0.9; }
.review-queue-priority-line.high { stroke: var(--red); }
.review-queue-priority-line.normal { stroke: var(--blue); }
.review-queue-priority-line.low { stroke: var(--muted); }
.review-queue-collapsed-range { position: static; flex: 0 0 auto; }
.review-priority { display: inline-block; padding: var(--sp-1) var(--sp-4); border-radius: var(--r-sm); font-size: var(--fs-2xs); color: var(--muted); border: 1px solid var(--blue); }
.review-priority-high { border-color: var(--red); }
.review-priority-low { border-color: var(--muted); }
Expand Down Expand Up @@ -5407,6 +5425,20 @@ <h4 style="font-size:0.95rem;margin:var(--sp-0) var(--sp-0) var(--sp-3)">What sh
}
const MINI_SUMMARY_HISTORY_PREFIX = 'hive_visual_summary_history:';
const MINI_SUMMARY_HISTORY_POINTS = 48;
const REVIEW_QUEUE_WINDOW_KEY = 'hive_review_queue_history_window';
const REVIEW_QUEUE_MS_PER_HOUR = 3600e3;
const REVIEW_QUEUE_HOURS_PER_DAY = 24;
const REVIEW_QUEUE_DAYS_PER_WEEK = 7;
const REVIEW_QUEUE_WINDOWS = { '24h': REVIEW_QUEUE_HOURS_PER_DAY * REVIEW_QUEUE_MS_PER_HOUR, '7d': REVIEW_QUEUE_DAYS_PER_WEEK * REVIEW_QUEUE_HOURS_PER_DAY * REVIEW_QUEUE_MS_PER_HOUR };
const REVIEW_QUEUE_SPARK_W = 128;
const REVIEW_QUEUE_SPARK_H = 24;
const REVIEW_QUEUE_SPARK_PAD_Y = 2;
let _reviewQueueWindow = (() => {
try {
const saved = localStorage.getItem(REVIEW_QUEUE_WINDOW_KEY);
return Object.prototype.hasOwnProperty.call(REVIEW_QUEUE_WINDOWS, saved) ? saved : '24h';
} catch { return '24h'; }
})();
function miniHiveKey() {
return String(((window._lastStatus || {}).hiveId) || 'default').trim() || 'default';
}
Expand Down Expand Up @@ -5501,6 +5533,86 @@ <h4 style="font-size:0.95rem;margin:var(--sp-0) var(--sp-0) var(--sp-3)">What sh
const title = label || text;
return `<svg class="mini-text" width="74" height="24" viewBox="0 0 74 24" role="img" aria-label="${escapeHtml(title)}">${miniTitleTag(title)}<text x="72" y="16" text-anchor="end" fill="currentColor" font-size="11" font-weight="800">${escapeHtml(text.slice(0, 10))}</text></svg>`;
}
function reviewQueueHistoryEntries() {
const cutoff = Date.now() - (REVIEW_QUEUE_WINDOWS[_reviewQueueWindow] || REVIEW_QUEUE_WINDOWS['24h']);
const rows = Array.isArray(window._reviewQueueHistory) ? window._reviewQueueHistory : [];
return rows.filter(row => {
const t = Number(row && row.t);
return Number.isFinite(t) && t >= cutoff;
});
}
function reviewQueueSeries(entries, key) {
return (entries || []).map(row => Number(row && row[key]) || 0);
}
function reviewQueueSparkPoints(values, min, max) {
const series = (Array.isArray(values) ? values : []).map(v => Number(v) || 0);
const range = (max - min) || max || 1;
const plotH = REVIEW_QUEUE_SPARK_H - REVIEW_QUEUE_SPARK_PAD_Y * 2;
if (series.length <= 1) {
const y = REVIEW_QUEUE_SPARK_PAD_Y + plotH - (((series[0] || 0) - min) / range) * plotH;
return `0,${y.toFixed(1)} ${REVIEW_QUEUE_SPARK_W.toFixed(1)},${y.toFixed(1)}`;
}
return series.map((v, i) => {
const x = (i / (series.length - 1)) * REVIEW_QUEUE_SPARK_W;
const y = REVIEW_QUEUE_SPARK_PAD_Y + plotH - ((v - min) / range) * plotH;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
}
function reviewQueueRangeControls() {
return `<span class="overview-kpi-range review-queue-collapsed-range" aria-label="Review Queue history range">${Object.keys(REVIEW_QUEUE_WINDOWS).map(win => `<button type="button" class="overview-kpi-range-btn${win === _reviewQueueWindow ? ' active' : ''}" data-action="setReviewQueueWindow" data-arg0="${escapeHtml(win)}" data-stop="1" aria-pressed="${win === _reviewQueueWindow ? 'true' : 'false'}" title="Show ${escapeHtml(win)} review queue history">${escapeHtml(win)}</button>`).join('')}</span>`;
}
function reviewQueueSparkline(entries) {
const total = reviewQueueSeries(entries, 'total');
const high = reviewQueueSeries(entries, 'high');
const normal = reviewQueueSeries(entries, 'normal');
const low = reviewQueueSeries(entries, 'low');
const values = total.length ? total : [0, 0];
const max = Math.max(1, ...values, ...high, ...normal, ...low);
const latest = values[values.length - 1] || 0;
const min = Math.min(0, ...values);
const title = `min ${fmtSparkVal(min)} · max ${fmtSparkVal(max)} · latest ${fmtSparkVal(latest)}`;
const after = [
['high', high, 'High priority'],
['normal', normal, 'Normal priority'],
['low', low, 'Low priority'],
].map(([key, series, label]) => series.length ? `<polyline class="review-queue-priority-line ${key}" points="${reviewQueueSparkPoints(series, min, max)}"><title>${escapeHtml(label)}: latest ${fmtSparkVal(series[series.length - 1] || 0)}</title></polyline>` : '').join('');
return `<span class="review-queue-spark">${renderSparkline(null, values, {
width: REVIEW_QUEUE_SPARK_W, height: REVIEW_QUEUE_SPARK_H, min, max, color: 'color-mix(in srgb, var(--purple) 60%, transparent)',
title, ariaLabel: `Review Queue total: ${title}`, label: 'review queue total', id: 'review-queue-collapsed',
dot: false, strokeWidth: 1, after, svgClass: 'mini-spark', wrapperClass: 'sparkline--flush', wrapperTitle: title,
})}</span>`;
}
function reviewQueueCollapsedSummary(text) {
const entries = reviewQueueHistoryEntries();
const latest = entries[entries.length - 1] || {};
const total = Number(latest.total);
const shownTotal = Number.isFinite(total) ? total : miniFirstNumber(text);
const hasPrioritySplit = Number.isFinite(Number(latest.high)) || Number.isFinite(Number(latest.normal)) || Number.isFinite(Number(latest.low));
const high = Number(latest.high) || 0;
const normal = hasPrioritySplit ? (Number(latest.normal) || 0) : shownTotal;
const low = Number(latest.low) || 0;
const first = entries[0] || {};
const startTotal = Number(first.total);
const delta = Number.isFinite(startTotal) ? shownTotal - startTotal : 0;
const deltaCls = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
const deltaText = delta > 0 ? `▲${delta}` : delta < 0 ? `▼${Math.abs(delta)}` : '•0';
const chips = [
['high', 'H', high],
['normal', 'N', normal],
['low', 'L', low],
].map(([key, label, value]) => `<span class="review-queue-chip ${key}" title="${escapeHtml(label)} priority PRs">${escapeHtml(label)} ${fmtSparkVal(value)}</span>`).join('');
const label = `${fmtSparkVal(shownTotal)} PRs · high ${high}, normal ${normal}, low ${low}, ${deltaText} vs range start`;
const sparkEntries = entries.length ? entries : [{ t: Date.now(), total: shownTotal, high, normal, low }];
const inner = `${reviewQueueRangeControls()}<span class="review-queue-collapsed-main"><span class="review-queue-collapsed-total">${fmtSparkVal(shownTotal)}</span>${chips}<span class="review-queue-delta ${deltaCls}" title="Delta vs range start">${deltaText}</span></span>${reviewQueueSparkline(sparkEntries)}`;
return `<span class="mini-summary-strip review-queue-collapsed" role="img" aria-label="${escapeHtml(label)}" title="${escapeHtml(label)}" data-mini-summary="review-queue-section">${inner}</span>`;
}
function setReviewQueueWindow(win) {
if (!Object.prototype.hasOwnProperty.call(REVIEW_QUEUE_WINDOWS, win)) return;
_reviewQueueWindow = win;
try { localStorage.setItem(REVIEW_QUEUE_WINDOW_KEY, win); } catch {}
setSectionSummary('review-queue-section', _reviewQueueTotal ? _reviewQueueTotal + ' PR' + (_reviewQueueTotal === 1 ? '' : 's') : '0 PRs');
}
window.setReviewQueueWindow = setReviewQueueWindow;
function miniAvatarDots(contributors, total, label) {
const list = Array.isArray(contributors) ? contributors.slice(0, 5) : [];
const dots = list.map((c, i) => `<circle cx="${8 + i * 12}" cy="12" r="5" fill="${_getAgentColor((c && (c.github_username || c.login || c.name)) || String(i))}"><title>${escapeHtml((c && (c.github_username || c.login || c.name)) || 'contributor')}</title></circle>`).join('');
Expand Down Expand Up @@ -5551,7 +5663,7 @@ <h4 style="font-size:0.95rem;margin:var(--sp-0) var(--sp-0) var(--sp-3)">What sh
} else if (sectionId === 'acmm-eval-section' || sectionId === 'acmm-reco-section') {
inner = miniDial(n0, ['L0','L1','L2','L3','L4','L5','L6'], text);
} else if (sectionId === 'review-queue-section') {
inner = miniGauge(n0, Math.max(10, n0), 'var(--purple)', text);
return reviewQueueCollapsedSummary(text);
} else if (sectionId === 'inception-section') {
inner = miniBar([{ label: 'proposals', value: n0, color: 'var(--blue)' }, { label: 'in progress', value: nums[1] || 0, color: 'var(--green)' }], text);
} else if (sectionId === 'contributors-section') {
Expand Down Expand Up @@ -20125,6 +20237,7 @@ <h3 class="modal-h">How Agents Use Knowledge (Priming)</h3>
var _reviewQueueLimit = REVIEW_QUEUE_PAGE_SIZE;
var _reviewQueueTotal = 0;
var _reviewQueueHasMore = false;
window._reviewQueueHistory = window._reviewQueueHistory || [];

// reviewQueuePriorityMeta maps the API's priority (high|normal|low) to the
// review-priority/* label family the backend mirrors onto PRs; the badge
Expand Down Expand Up @@ -20183,6 +20296,7 @@ <h3 class="modal-h">How Agents Use Knowledge (Priming)</h3>
_reviewQueueLimit = Number(data && data.limit) || REVIEW_QUEUE_PAGE_SIZE;
_reviewQueueOffset = Number(data && data.offset) || 0;
_reviewQueueHasMore = !!(data && data.has_more);
window._reviewQueueHistory = capDashboardArray((data && Array.isArray(data.history)) ? data.history : window._reviewQueueHistory, DASHBOARD_SERVER_HISTORY_CAP);
var countEl = document.getElementById('review-queue-count');
if (countEl) countEl.textContent = _reviewQueueTotal ? _reviewQueueTotal + ' PR' + (_reviewQueueTotal === 1 ? '' : 's') : '';
setSectionSummary('review-queue-section', _reviewQueueTotal ? _reviewQueueTotal + ' PR' + (_reviewQueueTotal === 1 ? '' : 's') : '0 PRs');
Expand Down
Loading