mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-05-15 14:38:03 +02:00
improve styling
This commit is contained in:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+965
-54
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,8 @@
|
|||||||
<h1 class="paper-page-title">Conferences</h1>
|
<h1 class="paper-page-title">Conferences</h1>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="/admin/"><i class="fas fa-code-branch"></i> Repositories</a>
|
<a href="/admin/"><i class="fas fa-tachometer-alt"></i> Overview</a>
|
||||||
|
<a href="/admin/repositories"><i class="fas fa-code-branch"></i> Repositories</a>
|
||||||
<a href="/admin/users"><i class="fas fa-users"></i> Users</a>
|
<a href="/admin/users"><i class="fas fa-users"></i> Users</a>
|
||||||
<a href="/admin/conferences" class="active"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
<a href="/admin/conferences" class="active"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
||||||
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
||||||
@@ -12,11 +13,11 @@
|
|||||||
|
|
||||||
<div class="admin-summary">
|
<div class="admin-summary">
|
||||||
<span class="summary-total">{{total >= 0 ? (total | number) : '…'}}</span>
|
<span class="summary-total">{{total >= 0 ? (total | number) : '…'}}</span>
|
||||||
<span class="summary-pill ok" ng-class="{active: query.status == 'ready'}" ng-click="query.status = query.status == 'ready' ? '' : 'ready'; query.page = 1">Ready <span class="count">{{statusCountFor('ready') | number}}</span></span>
|
<span class="summary-pill ok" ng-class="{active: query.ready}" ng-click="query.ready = !query.ready; query.page = 1" title="Toggle ready filter">Ready <span class="count">{{statusCountFor('ready') | number}}</span></span>
|
||||||
<span class="summary-pill warn" ng-class="{active: query.status == 'preparing'}" ng-click="query.status = query.status == 'preparing' ? '' : 'preparing'; query.page = 1">Preparing <span class="count">{{statusCountFor('preparing') | number}}</span></span>
|
<span class="summary-pill warn" ng-class="{active: query.preparing}" ng-click="query.preparing = !query.preparing; query.page = 1" title="Toggle preparing filter">Preparing <span class="count">{{statusCountFor('preparing') | number}}</span></span>
|
||||||
<span class="summary-pill error" ng-class="{active: query.status == 'error'}" ng-click="query.status = query.status == 'error' ? '' : 'error'; query.page = 1">Errored <span class="count">{{statusCountFor('error') | number}}</span></span>
|
<span class="summary-pill error" ng-class="{active: query.error}" ng-click="query.error = !query.error; query.page = 1" title="Toggle errored filter">Errored <span class="count">{{statusCountFor('error') | number}}</span></span>
|
||||||
<span class="summary-pill" ng-class="{active: query.status == 'expired'}" ng-click="query.status = query.status == 'expired' ? '' : 'expired'; query.page = 1">Expired <span class="count">{{statusCountFor('expired') | number}}</span></span>
|
<span class="summary-pill" ng-class="{active: query.expired}" ng-click="query.expired = !query.expired; query.page = 1" title="Toggle expired filter">Expired <span class="count">{{statusCountFor('expired') | number}}</span></span>
|
||||||
<span class="summary-pill" ng-class="{active: query.status == 'removed'}" ng-click="query.status = query.status == 'removed' ? '' : 'removed'; query.page = 1">Removed <span class="count">{{statusCountFor('removed') | number}}</span></span>
|
<span class="summary-pill" ng-class="{active: query.removed}" ng-click="query.removed = !query.removed; query.page = 1" title="Toggle removed filter">Removed <span class="count">{{statusCountFor('removed') | number}}</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-danger" ng-if="fetchError" style="margin: 8px 0;">
|
<div class="alert alert-danger" ng-if="fetchError" style="margin: 8px 0;">
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
<div
|
<div
|
||||||
class="paper-table-row"
|
class="paper-table-row"
|
||||||
role="row"
|
role="row"
|
||||||
ng-repeat="conference in conferences | filter:conferenceFiler | orderBy:orderBy as filteredConferences"
|
ng-repeat="conference in conferences as filteredConferences"
|
||||||
>
|
>
|
||||||
<div class="cell-anon" role="cell">
|
<div class="cell-anon" role="cell">
|
||||||
<span class="type-badge type-repo">Conf</span>
|
<span class="type-badge type-repo">Conf</span>
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="admin-filter-inline">
|
<span class="admin-filter-inline">
|
||||||
<label>Per page</label>
|
<label>Per page</label>
|
||||||
<select class="form-control form-control-sm" ng-model="query.limit"><option value="10">10</option><option value="25">25</option><option value="50">50</option><option value="100">100</option></select>
|
<select class="form-control form-control-sm" ng-model="query.limit"><option value="10">10</option><option value="25">25</option><option value="50">50</option><option value="100">100</option><option value="250">250</option></select>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="/admin/"><i class="fas fa-code-branch"></i> Repositories</a>
|
<a href="/admin/"><i class="fas fa-tachometer-alt"></i> Overview</a>
|
||||||
|
<a href="/admin/repositories"><i class="fas fa-code-branch"></i> Repositories</a>
|
||||||
<a href="/admin/users"><i class="fas fa-users"></i> Users</a>
|
<a href="/admin/users"><i class="fas fa-users"></i> Users</a>
|
||||||
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
||||||
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<div class="container paper-page admin-page overview-page">
|
||||||
|
<div class="paper-crumbs">Admin · System Health</div>
|
||||||
|
|
||||||
|
<div class="ov-header">
|
||||||
|
<h1 class="paper-page-title">Overview</h1>
|
||||||
|
<div class="ov-header-actions">
|
||||||
|
<div class="ov-range-btns">
|
||||||
|
<button class="btn btn-sm" ng-class="{active: range === '1h'}" ng-click="setRange('1h')">1h</button>
|
||||||
|
<button class="btn btn-sm" ng-class="{active: range === '6h'}" ng-click="setRange('6h')">6h</button>
|
||||||
|
<button class="btn btn-sm" ng-class="{active: range === '24h'}" ng-click="setRange('24h')">24h</button>
|
||||||
|
<button class="btn btn-sm" ng-class="{active: range === '7d'}" ng-click="setRange('7d')">7d</button>
|
||||||
|
<button class="btn btn-sm" ng-class="{active: range === '30d'}" ng-click="setRange('30d')">30d</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a href="/admin/" class="active"><i class="fas fa-tachometer-alt"></i> Overview</a>
|
||||||
|
<a href="/admin/repositories"><i class="fas fa-code-branch"></i> Repositories</a>
|
||||||
|
<a href="/admin/users"><i class="fas fa-users"></i> Users</a>
|
||||||
|
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
||||||
|
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
||||||
|
<a href="/admin/errors"><i class="fas fa-bug"></i> Errors</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div ng-if="loading" class="admin-empty">Loading overview…</div>
|
||||||
|
<div ng-if="error" class="alert alert-danger" style="margin:12px 0"><i class="fas fa-exclamation-triangle"></i> {{error}}</div>
|
||||||
|
|
||||||
|
<div ng-if="data">
|
||||||
|
|
||||||
|
<!-- ── Top KPI row ──────────────────────────────────────────── -->
|
||||||
|
<section class="ov-kpi-row">
|
||||||
|
<div class="ov-kpi-card">
|
||||||
|
<div class="ov-kpi-label">Repositories <span class="ov-dot ov-dot-ok"></span></div>
|
||||||
|
<div class="ov-kpi-value">{{humanNum(data.repos.total)}}</div>
|
||||||
|
<div class="ov-kpi-sub">+{{data.repos.newRepos24h}} · last 24h</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-kpi-card">
|
||||||
|
<div class="ov-kpi-label">CPU</div>
|
||||||
|
<div class="ov-kpi-value" ng-class="{'ov-val-warn': data.system.cpuPercent > 80}">{{data.system.cpuPercent}}%</div>
|
||||||
|
<div class="ov-kpi-sub">{{data.system.cpuCount}} cores · load {{data.system.loadAvg[0].toFixed(1)}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-kpi-card">
|
||||||
|
<div class="ov-kpi-label">Memory</div>
|
||||||
|
<div class="ov-kpi-value" ng-class="{'ov-val-warn': data.system.memPercent > 85}">{{data.system.memPercent}}%</div>
|
||||||
|
<div class="ov-kpi-sub">{{humanBytes(data.system.memUsed)}} / {{humanBytes(data.system.memTotal)}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-kpi-card">
|
||||||
|
<div class="ov-kpi-label">Disk</div>
|
||||||
|
<div class="ov-kpi-value" ng-class="{'ov-val-warn': data.system.diskPercent > 85}">{{data.system.diskPercent}}%</div>
|
||||||
|
<div class="ov-kpi-sub">{{humanBytes(data.system.diskUsed)}} / {{humanBytes(data.system.diskTotal)}} · {{data.system.diskMount}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-kpi-card">
|
||||||
|
<div class="ov-kpi-label">Uptime</div>
|
||||||
|
<div class="ov-kpi-value">{{humanDuration(data.system.uptime)}}</div>
|
||||||
|
<div class="ov-kpi-sub">{{data.system.nodeVersion}} · {{data.system.platform}}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Two wide charts ──────────────────────────────────────── -->
|
||||||
|
<section class="ov-chart-row">
|
||||||
|
<div class="ov-chart-card">
|
||||||
|
<div class="ov-chart-head">
|
||||||
|
<span class="ov-chart-title">Page views · 30d</span>
|
||||||
|
<span class="ov-chart-legend"><span class="ov-dot-legend ov-dot-accent"></span>views</span>
|
||||||
|
</div>
|
||||||
|
<div class="ov-spark-bars" ng-if="data.history.length">
|
||||||
|
<div class="ov-spark-col" ng-repeat="d in data.history track by $index"
|
||||||
|
title="{{historyLabel(d)}}: {{d.nbPageViews | number}} views">
|
||||||
|
<span class="ov-spark-fill" ng-style="{height: historyBarH(d, 'nbPageViews') + 'px'}"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-spark-x" ng-if="data.history.length">
|
||||||
|
<span>{{historyLabel(data.history[0])}}</span>
|
||||||
|
<span>{{historyLabel(data.history[Math.floor(data.history.length/2)])}}</span>
|
||||||
|
<span>{{historyLabel(data.history[data.history.length - 1])}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-chart-card">
|
||||||
|
<div class="ov-chart-head">
|
||||||
|
<span class="ov-chart-title">New repos · 30d</span>
|
||||||
|
<span class="ov-chart-legend"><span class="ov-dot-legend ov-dot-ok-fill"></span>repos</span>
|
||||||
|
</div>
|
||||||
|
<div class="ov-spark-bars" ng-if="data.history.length">
|
||||||
|
<div class="ov-spark-col" ng-repeat="d in data.history track by $index"
|
||||||
|
title="{{historyLabel(d)}}: {{d.nbRepositories | number}} repos">
|
||||||
|
<span class="ov-spark-fill ov-spark-fill-alt" ng-style="{height: historyBarH(d, 'nbRepositories') + 'px'}"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-spark-x" ng-if="data.history.length">
|
||||||
|
<span>{{historyLabel(data.history[0])}}</span>
|
||||||
|
<span>{{historyLabel(data.history[Math.floor(data.history.length/2)])}}</span>
|
||||||
|
<span>{{historyLabel(data.history[data.history.length - 1])}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Three-panel row: Status / Errors / Queues ────────────── -->
|
||||||
|
<section class="ov-triple-row">
|
||||||
|
|
||||||
|
<!-- Repo status breakdown -->
|
||||||
|
<div class="ov-panel-card">
|
||||||
|
<div class="ov-panel-head">
|
||||||
|
<span class="ov-panel-title">Repo status</span>
|
||||||
|
<span class="ov-panel-meta">{{data.repos.total | number}} total</span>
|
||||||
|
</div>
|
||||||
|
<div class="ov-stacked-bar">
|
||||||
|
<span class="ov-bar-seg ov-bar-ready" ng-style="{width: barPct('ready') + '%'}" title="Ready"></span>
|
||||||
|
<span class="ov-bar-seg ov-bar-preparing" ng-style="{width: barPct('preparing') + '%'}" title="Preparing"></span>
|
||||||
|
<span class="ov-bar-seg ov-bar-error" ng-style="{width: barPct('error') + '%'}" title="Error"></span>
|
||||||
|
<span class="ov-bar-seg ov-bar-expired" ng-style="{width: barPct('expired') + '%'}" title="Expired"></span>
|
||||||
|
<span class="ov-bar-seg ov-bar-removed" ng-style="{width: barPct('removed') + '%'}" title="Removed"></span>
|
||||||
|
</div>
|
||||||
|
<div class="ov-bar-legend">
|
||||||
|
<a href="/admin/repositories" class="ov-legend-item"><span class="ov-swatch ov-bar-ready"></span> ready <span class="ov-legend-n">{{statusCount('ready') | number}}</span></a>
|
||||||
|
<a href="/admin/repositories" class="ov-legend-item"><span class="ov-swatch ov-bar-preparing"></span> preparing <span class="ov-legend-n">{{statusCount('preparing') + statusCount('download') | number}}</span></a>
|
||||||
|
<a href="/admin/repositories" class="ov-legend-item"><span class="ov-swatch ov-bar-error"></span> error <span class="ov-legend-n">{{statusCount('error') | number}}</span></a>
|
||||||
|
<a href="/admin/repositories" class="ov-legend-item"><span class="ov-swatch ov-bar-expired"></span> expired <span class="ov-legend-n">{{statusCount('expired') + statusCount('expiring') | number}}</span></a>
|
||||||
|
<a href="/admin/repositories" class="ov-legend-item"><span class="ov-swatch ov-bar-removed"></span> removed <span class="ov-legend-n">{{statusCount('removed') + statusCount('removing') | number}}</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error breakdown -->
|
||||||
|
<div class="ov-panel-card">
|
||||||
|
<div class="ov-panel-head">
|
||||||
|
<span class="ov-panel-title">Errors · 24h</span>
|
||||||
|
<span class="ov-panel-meta">{{data.errors.last24h}} total</span>
|
||||||
|
</div>
|
||||||
|
<div class="ov-error-bars">
|
||||||
|
<div class="ov-ebar-row">
|
||||||
|
<span class="ov-ebar-label">5xx</span>
|
||||||
|
<span class="ov-ebar-track"><span class="ov-ebar-fill ov-ebar-error" ng-style="{width: errPct('error') + '%'}"></span></span>
|
||||||
|
<span class="ov-ebar-n">{{data.errors.severity.error}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ov-ebar-row">
|
||||||
|
<span class="ov-ebar-label">4xx</span>
|
||||||
|
<span class="ov-ebar-track"><span class="ov-ebar-fill ov-ebar-warn" ng-style="{width: errPct('warn') + '%'}"></span></span>
|
||||||
|
<span class="ov-ebar-n">{{data.errors.severity.warn}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ov-ebar-row">
|
||||||
|
<span class="ov-ebar-label">Info</span>
|
||||||
|
<span class="ov-ebar-track"><span class="ov-ebar-fill ov-ebar-info" ng-style="{width: errPct('info') + '%'}"></span></span>
|
||||||
|
<span class="ov-ebar-n">{{data.errors.severity.info}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-panel-foot" ng-if="data.repos.recentErrors24h">
|
||||||
|
<a href="/admin/errors">{{data.repos.recentErrors24h}} repos in error state →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top routes / queues -->
|
||||||
|
<div class="ov-panel-card">
|
||||||
|
<div class="ov-panel-head">
|
||||||
|
<span class="ov-panel-title">Queues</span>
|
||||||
|
<span class="ov-panel-meta">by state</span>
|
||||||
|
</div>
|
||||||
|
<div class="ov-routes-table">
|
||||||
|
<div class="ov-route-row ov-route-head">
|
||||||
|
<span class="ov-route-name">Queue</span>
|
||||||
|
<span class="ov-route-n">Active</span>
|
||||||
|
<span class="ov-route-n">Wait</span>
|
||||||
|
<span class="ov-route-n ov-route-lat">Failed</span>
|
||||||
|
</div>
|
||||||
|
<a class="ov-route-row" href="/admin/queues">
|
||||||
|
<span class="ov-route-name">download</span>
|
||||||
|
<span class="ov-route-n">{{data.queues.download.active}}</span>
|
||||||
|
<span class="ov-route-n">{{data.queues.download.waiting}}</span>
|
||||||
|
<span class="ov-route-n ov-route-lat" ng-class="{'ov-n-bad': data.queues.download.failed > 0}">{{data.queues.download.failed}}</span>
|
||||||
|
</a>
|
||||||
|
<a class="ov-route-row" href="/admin/queues">
|
||||||
|
<span class="ov-route-name">remove</span>
|
||||||
|
<span class="ov-route-n">{{data.queues.remove.active}}</span>
|
||||||
|
<span class="ov-route-n">{{data.queues.remove.waiting}}</span>
|
||||||
|
<span class="ov-route-n ov-route-lat" ng-class="{'ov-n-bad': data.queues.remove.failed > 0}">{{data.queues.remove.failed}}</span>
|
||||||
|
</a>
|
||||||
|
<a class="ov-route-row" href="/admin/queues">
|
||||||
|
<span class="ov-route-name">cache</span>
|
||||||
|
<span class="ov-route-n">{{data.queues.cache.active}}</span>
|
||||||
|
<span class="ov-route-n">{{data.queues.cache.waiting}}</span>
|
||||||
|
<span class="ov-route-n ov-route-lat" ng-class="{'ov-n-bad': data.queues.cache.failed > 0}">{{data.queues.cache.failed}}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Services bar ─────────────────────────────────────────── -->
|
||||||
|
<section class="ov-services-card">
|
||||||
|
<div class="ov-services-head">
|
||||||
|
<span class="ov-panel-title">Services</span>
|
||||||
|
<span class="ov-panel-meta">{{data.users.total | number}} users · {{data.conferences.total}} conferences</span>
|
||||||
|
</div>
|
||||||
|
<div class="ov-services-grid">
|
||||||
|
<div class="ov-svc">
|
||||||
|
<span class="ov-svc-dot ov-dot-ok"></span>
|
||||||
|
<div class="ov-svc-info">
|
||||||
|
<span class="ov-svc-name">web</span>
|
||||||
|
<span class="ov-svc-meta">{{data.system.nodeVersion}}</span>
|
||||||
|
</div>
|
||||||
|
<span class="ov-svc-detail">uptime {{humanDuration(data.system.uptime)}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ov-svc">
|
||||||
|
<span class="ov-svc-dot" ng-class="queueTotal(data.queues.download) > 0 ? 'ov-dot-ok' : 'ov-dot-idle'"></span>
|
||||||
|
<div class="ov-svc-info">
|
||||||
|
<span class="ov-svc-name">download</span>
|
||||||
|
<span class="ov-svc-meta" ng-if="queueTotal(data.queues.download)">{{queueTotal(data.queues.download)}} jobs</span>
|
||||||
|
<span class="ov-svc-meta" ng-if="!queueTotal(data.queues.download)">idle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-svc">
|
||||||
|
<span class="ov-svc-dot" ng-class="queueTotal(data.queues.cache) > 0 ? 'ov-dot-ok' : 'ov-dot-idle'"></span>
|
||||||
|
<div class="ov-svc-info">
|
||||||
|
<span class="ov-svc-name">cache</span>
|
||||||
|
<span class="ov-svc-meta" ng-if="queueTotal(data.queues.cache)">{{queueTotal(data.queues.cache)}} jobs</span>
|
||||||
|
<span class="ov-svc-meta" ng-if="!queueTotal(data.queues.cache)">idle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-svc">
|
||||||
|
<span class="ov-svc-dot" ng-class="queueTotal(data.queues.remove) > 0 ? 'ov-dot-ok' : 'ov-dot-idle'"></span>
|
||||||
|
<div class="ov-svc-info">
|
||||||
|
<span class="ov-svc-name">remove</span>
|
||||||
|
<span class="ov-svc-meta" ng-if="queueTotal(data.queues.remove)">{{queueTotal(data.queues.remove)}} jobs</span>
|
||||||
|
<span class="ov-svc-meta" ng-if="!queueTotal(data.queues.remove)">idle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ov-svc">
|
||||||
|
<span class="ov-svc-dot" ng-class="data.repos.recentErrors24h > 10 ? 'ov-dot-warn' : 'ov-dot-ok'"></span>
|
||||||
|
<div class="ov-svc-info">
|
||||||
|
<span class="ov-svc-name">errors</span>
|
||||||
|
<span class="ov-svc-meta">{{data.errors.last24h}} / 24h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,102 +1,156 @@
|
|||||||
<div class="container paper-page admin-page">
|
<div class="container paper-page admin-page">
|
||||||
<div class="paper-crumbs">Admin / <span class="here">Queues</span></div>
|
<div class="paper-crumbs">Admin / <span class="here">Queues</span></div>
|
||||||
<h1 class="paper-page-title">Queues</h1>
|
|
||||||
|
<div class="q-header">
|
||||||
|
<h1 class="paper-page-title">Queues</h1>
|
||||||
|
<div class="q-header-actions">
|
||||||
|
<div class="q-range-btns">
|
||||||
|
<button class="btn btn-sm" ng-class="{active: range == '1h'}" ng-click="setRange('1h')">1h</button>
|
||||||
|
<button class="btn btn-sm" ng-class="{active: range == '6h'}" ng-click="setRange('6h')">6h</button>
|
||||||
|
<button class="btn btn-sm" ng-class="{active: range == '24h'}" ng-click="setRange('24h')">24h</button>
|
||||||
|
<button class="btn btn-sm" ng-class="{active: range == '7d'}" ng-click="setRange('7d')">7d</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm" ng-click="pauseAll()">Pause all</button>
|
||||||
|
<button class="btn btn-sm btn-dark" ng-click="drainSelected()">Drain {{selectedQueue}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="/admin/"><i class="fas fa-code-branch"></i> Repositories</a>
|
<a href="/admin/"><i class="fas fa-tachometer-alt"></i> Overview</a>
|
||||||
|
<a href="/admin/repositories"><i class="fas fa-code-branch"></i> Repositories</a>
|
||||||
<a href="/admin/users"><i class="fas fa-users"></i> Users</a>
|
<a href="/admin/users"><i class="fas fa-users"></i> Users</a>
|
||||||
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
||||||
<a href="/admin/queues" class="active"><i class="fas fa-tasks"></i> Queues</a>
|
<a href="/admin/queues" class="active"><i class="fas fa-tasks"></i> Queues</a>
|
||||||
<a href="/admin/errors"><i class="fas fa-bug"></i> Errors</a>
|
<a href="/admin/errors"><i class="fas fa-bug"></i> Errors</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="admin-summary">
|
<!-- Queue overview cards -->
|
||||||
<span class="summary-pill warn" ng-class="{active: query.state == 'active'}" ng-click="query.state = query.state == 'active' ? '' : 'active'">Active <span class="count">{{(counts.download.active || 0) + (counts.remove.active || 0) + (counts.cache.active || 0)}}</span></span>
|
<div class="q-cards">
|
||||||
<span class="summary-pill" ng-class="{active: query.state == 'waiting'}" ng-click="query.state = query.state == 'waiting' ? '' : 'waiting'">Waiting <span class="count">{{(counts.download.waiting || 0) + (counts.remove.waiting || 0) + (counts.cache.waiting || 0)}}</span></span>
|
<div class="q-card" ng-repeat="q in queueList" ng-class="{selected: selectedQueue == q.key, paused: q.paused}" ng-click="selectQueue(q.key)">
|
||||||
<span class="summary-pill error" ng-class="{active: query.state == 'failed'}" ng-click="query.state = query.state == 'failed' ? '' : 'failed'">Failed <span class="count">{{(counts.download.failed || 0) + (counts.remove.failed || 0) + (counts.cache.failed || 0)}}</span></span>
|
<div class="q-card-head">
|
||||||
<span class="summary-pill ok" ng-class="{active: query.state == 'completed'}" ng-click="query.state = query.state == 'completed' ? '' : 'completed'">Completed <span class="count">{{(counts.download.completed || 0) + (counts.remove.completed || 0) + (counts.cache.completed || 0)}}</span></span>
|
<span class="q-dot" ng-class="{'q-dot-red': q.paused || q.counts.failed > 0}"></span>
|
||||||
<span class="summary-pill" ng-class="{active: query.state == 'delayed'}" ng-click="query.state = query.state == 'delayed' ? '' : 'delayed'">Delayed <span class="count">{{(counts.download.delayed || 0) + (counts.remove.delayed || 0) + (counts.cache.delayed || 0)}}</span></span>
|
<span class="q-card-name" ng-bind="q.label"></span>
|
||||||
|
</div>
|
||||||
|
<div class="q-card-count" ng-bind="(q.counts.waiting || 0) + (q.counts.active || 0) + (q.counts.delayed || 0)"></div>
|
||||||
|
<div class="q-card-sub">
|
||||||
|
<span>waiting · {{q.counts.active || 0}} active</span>
|
||||||
|
<div class="q-card-bar" ng-if="q.counts.active">
|
||||||
|
<div class="q-card-bar-fill" ng-style="{width: (q.counts.active / ((q.counts.waiting || 0) + (q.counts.active || 0) + (q.counts.delayed || 0) || 1) * 100) + '%'}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="w-100 admin-filter-toolbar" aria-label="Queue filters">
|
<!-- Detail: throughput chart + stats panel -->
|
||||||
<div class="admin-filter-row">
|
<div class="q-detail" ng-if="selectedStats">
|
||||||
<div class="search-wrap">
|
<div class="q-throughput">
|
||||||
<input type="search" class="form-control" placeholder="Search by job/repo id…" ng-model="query.search" autocomplete="off" />
|
<div class="q-section-label">{{selectedQueue}}·throughput <span class="q-section-right">COMPLETED / MIN · {{range | uppercase}}</span></div>
|
||||||
<span class="admin-search-hint" ng-if="!query.search">/</span>
|
<canvas id="q-throughput-chart" height="180"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="q-stats-panel">
|
||||||
|
<div class="q-section-label">{{selectedQueue}}·stats</div>
|
||||||
|
<div class="q-stats-grid">
|
||||||
|
<div class="q-stat">
|
||||||
|
<div class="q-stat-label">WAITING</div>
|
||||||
|
<div class="q-stat-value" ng-bind="selectedStats.counts.waiting || 0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="q-stat">
|
||||||
|
<div class="q-stat-label">ACTIVE</div>
|
||||||
|
<div class="q-stat-value" ng-bind="selectedStats.counts.active || 0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="q-stat">
|
||||||
|
<div class="q-stat-label">COMPLETED (24H)</div>
|
||||||
|
<div class="q-stat-value" ng-bind="selectedStats.completed24h | number"></div>
|
||||||
|
</div>
|
||||||
|
<div class="q-stat">
|
||||||
|
<div class="q-stat-label">FAILED (24H)</div>
|
||||||
|
<div class="q-stat-value" ng-bind="selectedStats.failed24h || 0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="q-stat">
|
||||||
|
<div class="q-stat-label">DELAYED</div>
|
||||||
|
<div class="q-stat-value" ng-bind="selectedStats.counts.delayed || 0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="q-stat">
|
||||||
|
<div class="q-stat-label">WORKERS</div>
|
||||||
|
<div class="q-stat-value" ng-bind="selectedStats.workers || 0"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="admin-filter-inline">
|
<div class="q-stats-actions">
|
||||||
<label>State</label>
|
<button class="btn btn-sm" ng-click="togglePause()">{{selectedStats.paused ? 'Resume' : 'Pause'}}</button>
|
||||||
<select class="form-control form-control-sm" ng-model="query.state">
|
<button class="btn btn-sm" ng-click="retryFailed()" ng-disabled="!selectedStats.counts.failed">Retry failed</button>
|
||||||
<option value="">Any</option>
|
<button class="btn btn-sm" ng-click="emptyQueue()">Empty</button>
|
||||||
<option value="waiting">Waiting</option>
|
</div>
|
||||||
<option value="active">Active</option>
|
</div>
|
||||||
<option value="completed">Completed</option>
|
</div>
|
||||||
<option value="failed">Failed</option>
|
|
||||||
<option value="delayed">Delayed</option>
|
<!-- Jobs table -->
|
||||||
</select>
|
<div class="q-jobs">
|
||||||
</span>
|
<div class="q-jobs-header">
|
||||||
<span class="admin-filter-spacer"></span>
|
<div class="q-section-label">{{query.state | uppercase}} JOBS · {{selectedQueue | uppercase}}</div>
|
||||||
<label class="admin-filter-inline" style="cursor:pointer;">
|
<div class="q-tabs">
|
||||||
|
<button class="q-tab" ng-class="{active: query.state == 'active'}" ng-click="query.state = 'active'">Active</button>
|
||||||
|
<button class="q-tab" ng-class="{active: query.state == 'waiting'}" ng-click="query.state = 'waiting'">Waiting</button>
|
||||||
|
<button class="q-tab" ng-class="{active: query.state == 'completed'}" ng-click="query.state = 'completed'">Completed</button>
|
||||||
|
<button class="q-tab" ng-class="{active: query.state == 'failed'}" ng-click="query.state = 'failed'">Failed</button>
|
||||||
|
<button class="q-tab" ng-class="{active: query.state == 'delayed'}" ng-click="query.state = 'delayed'">Delayed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-search-row">
|
||||||
|
<input type="search" class="form-control" placeholder="Search by job/repo id…" ng-model="query.search" autocomplete="off" />
|
||||||
|
<label class="q-auto-refresh">
|
||||||
<input type="checkbox" ng-model="query.autoRefresh" />
|
<input type="checkbox" ng-model="query.autoRefresh" />
|
||||||
Auto-refresh
|
Auto-refresh
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-sm" type="button" ng-click="refreshNow()" title="Refresh now"><i class="fas fa-sync"></i></button>
|
<button class="btn btn-sm" type="button" ng-click="refreshNow()" title="Refresh now"><i class="fas fa-sync"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<div ng-repeat="qInfo in [
|
<table class="q-table" ng-if="jobs.length > 0">
|
||||||
{key: 'download', label: 'Download jobs', icon: 'fa-download', jobs: downloadJobs, counts: counts.download},
|
<thead>
|
||||||
{key: 'remove', label: 'Remove jobs', icon: 'fa-trash', jobs: removeJobs, counts: counts.remove},
|
<tr>
|
||||||
{key: 'cache', label: 'Cache cleanup jobs', icon: 'fa-broom', jobs: removeCaches, counts: counts.cache}
|
<th>JOB ID</th>
|
||||||
]">
|
<th>PAYLOAD</th>
|
||||||
<div class="admin-section-header">
|
<th>ATTEMPTS</th>
|
||||||
<h2><i class="fas {{qInfo.icon}}"></i> {{qInfo.label}}</h2>
|
<th>DURATION</th>
|
||||||
<span class="section-count">{{qInfo.jobs.length || 0}}</span>
|
<th>PROGRESS</th>
|
||||||
<span class="queue-state-pills">
|
<th></th>
|
||||||
<span class="pill pill-waiting" ng-if="qInfo.counts.waiting">{{qInfo.counts.waiting}} waiting</span>
|
</tr>
|
||||||
<span class="pill pill-active" ng-if="qInfo.counts.active">{{qInfo.counts.active}} active</span>
|
</thead>
|
||||||
<span class="pill pill-completed" ng-if="qInfo.counts.completed">{{qInfo.counts.completed}} done</span>
|
<tbody>
|
||||||
<span class="pill pill-failed" ng-if="qInfo.counts.failed">{{qInfo.counts.failed}} failed</span>
|
<tr ng-repeat="job in jobs" ng-class="{'q-row-failed': job.failedReason || job.stacktrace.length}">
|
||||||
<span class="pill pill-delayed" ng-if="qInfo.counts.delayed">{{qInfo.counts.delayed}} delayed</span>
|
<td class="q-cell-id">
|
||||||
</span>
|
<a target="_blank" ng-href="/r/{{job.id}}" ng-bind="'job:' + (job.id | limitTo:6)"></a>
|
||||||
<span style="margin-left: auto; display: inline-flex; gap: 6px;">
|
</td>
|
||||||
<button class="btn btn-sm" type="button" ng-click="bulkRetryFailed(qInfo.key)" ng-disabled="!qInfo.counts.failed"><i class="fas fa-redo"></i> Retry all failed</button>
|
<td class="q-cell-payload">
|
||||||
<button class="btn btn-sm text-danger" type="button" ng-click="bulkDrain(qInfo.key)"><i class="fas fa-eraser"></i> Drain</button>
|
<span ng-bind="job.name || 'anonymize'"></span>
|
||||||
</span>
|
<span class="q-payload-detail" ng-if="job.data.repoId"> · {{job.data.repoId}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="q-cell-num" ng-bind="job.attemptsMade || 1"></td>
|
||||||
|
<td class="q-cell-num" ng-bind="jobDuration(job)"></td>
|
||||||
|
<td class="q-cell-progress">
|
||||||
|
<div class="q-progress-wrap" ng-if="jobProgressPct(job) !== null">
|
||||||
|
<div class="q-progress-bar" ng-style="{'--pct': jobProgressPct(job) + '%'}"></div>
|
||||||
|
<span class="q-progress-label" ng-bind="jobProgressPct(job) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="q-cell-actions">
|
||||||
|
<button class="btn btn-sm" ng-click="retryJob(job)" title="Retry"><i class="fas fa-sync"></i></button>
|
||||||
|
<button class="btn btn-sm" ng-click="removeJob(job)" title="Remove"><i class="fas fa-trash-alt"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Expanded error detail -->
|
||||||
|
<div ng-repeat="job in jobs" ng-if="job.failedReason || job.stacktrace.length" class="q-error-detail" style="display:none;">
|
||||||
|
<div ng-if="job.failedReason" class="q-error-reason" ng-bind="job.failedReason"></div>
|
||||||
|
<pre ng-repeat="stack in job.stacktrace track by $index" class="q-error-stack"><code ng-bind="stack"></code></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="queue-job-card" ng-repeat="job in qInfo.jobs | filter:jobMatchesState as filteredJobs">
|
<div class="paper-table-empty" ng-if="jobs.length == 0" style="border:1px solid var(--border-color);border-radius:10px;background:var(--paper-card);">
|
||||||
<div class="job-header">
|
|
||||||
<div class="job-id">
|
|
||||||
<span class="status-dot" ng-class="{'status-ready': job.progress.status == 'ready', 'status-error': job.progress.status == 'error' || (job.stacktrace && job.stacktrace.length), 'status-preparing': job.progress.status == 'preparing' || (job.processedOn && !job.finishedOn), 'status-removed': job.progress.status == 'removed'}"></span>
|
|
||||||
<a target="_blank" ng-href="/r/{{job.id}}" ng-bind="job.id"></a>
|
|
||||||
<span ng-bind="job.progress.status | title" style="font-family: var(--font-sans); color: var(--ink-muted); font-size: 12px;"></span>
|
|
||||||
</div>
|
|
||||||
<div ng-if="jobProgressPct(job) !== null" class="job-progress" title="{{jobProgressPct(job)}}%">
|
|
||||||
<div class="job-progress-bar" style="width: {{jobProgressPct(job)}}%;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="job-timestamps">
|
|
||||||
<span ng-if="job.timestamp"><i class="fas fa-clock"></i> Created: {{job.timestamp | humanTime}}</span>
|
|
||||||
<span ng-if="job.processedOn"><i class="fas fa-cog"></i> Processed: {{job.processedOn | humanTime}}</span>
|
|
||||||
<span ng-if="job.finishedOn"><i class="fas fa-check"></i> Finished: {{job.finishedOn | humanTime}}</span>
|
|
||||||
<span ng-if="job.attemptsMade"><i class="fas fa-redo"></i> Attempts: {{job.attemptsMade}}</span>
|
|
||||||
</div>
|
|
||||||
<div ng-if="job.failedReason" style="color:#B42318; font-size:0.8rem; margin-top:4px;">{{job.failedReason}}</div>
|
|
||||||
<div ng-if="job.stacktrace.length">
|
|
||||||
<pre ng-repeat="stack in job.stacktrace track by $index" style="font-size: 0.8rem; max-height: 100px; overflow: auto; margin: 6px 0 0 0"><code ng-bind="stack"></code></pre>
|
|
||||||
</div>
|
|
||||||
<div class="job-actions">
|
|
||||||
<button class="btn btn-sm" ng-click="retryJob(qInfo.key, job)"><i class="fas fa-sync"></i> Retry</button>
|
|
||||||
<button class="btn btn-sm" ng-click="removeJob(qInfo.key, job)"><i class="fas fa-trash-alt"></i> Remove</button>
|
|
||||||
<a class="btn btn-sm" href="/anonymize/{{job.id}}"><i class="far fa-edit"></i> Edit</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="paper-table-empty" ng-if="filteredJobs.length == 0" style="border:1px solid var(--border-color);border-radius:10px;background:var(--paper-card);">
|
|
||||||
<i class="fas fa-check-circle"></i>
|
<i class="fas fa-check-circle"></i>
|
||||||
<span ng-if="!query.search && !query.state">No {{qInfo.label | lowercase}} in the queue.</span>
|
<span ng-if="!query.search">No {{query.state}} jobs in the {{selectedQueue}} queue.</span>
|
||||||
<span ng-if="query.search || query.state">No jobs match the current filters.</span>
|
<span ng-if="query.search">No jobs match the current filters.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<h1 class="paper-page-title">Repositories</h1>
|
<h1 class="paper-page-title">Repositories</h1>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="/admin/" class="active"><i class="fas fa-code-branch"></i> Repositories</a>
|
<a href="/admin/"><i class="fas fa-tachometer-alt"></i> Overview</a>
|
||||||
|
<a href="/admin/repositories" class="active"><i class="fas fa-code-branch"></i> Repositories</a>
|
||||||
<a href="/admin/users"><i class="fas fa-users"></i> Users</a>
|
<a href="/admin/users"><i class="fas fa-users"></i> Users</a>
|
||||||
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
||||||
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<h1 class="paper-page-title">{{userInfo.username || 'User'}}</h1>
|
<h1 class="paper-page-title">{{userInfo.username || 'User'}}</h1>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="/admin/"><i class="fas fa-code-branch"></i> Repositories</a>
|
<a href="/admin/"><i class="fas fa-tachometer-alt"></i> Overview</a>
|
||||||
|
<a href="/admin/repositories"><i class="fas fa-code-branch"></i> Repositories</a>
|
||||||
<a href="/admin/users" class="active"><i class="fas fa-users"></i> Users</a>
|
<a href="/admin/users" class="active"><i class="fas fa-users"></i> Users</a>
|
||||||
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
||||||
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
||||||
@@ -25,6 +26,8 @@
|
|||||||
<div class="user-actions" style="margin-top: 4px;">
|
<div class="user-actions" style="margin-top: 4px;">
|
||||||
<button class="btn btn-sm text-danger" ng-if="userInfo.status !== 'banned'" ng-click="banUser()"><i class="fas fa-ban"></i> Ban</button>
|
<button class="btn btn-sm text-danger" ng-if="userInfo.status !== 'banned'" ng-click="banUser()"><i class="fas fa-ban"></i> Ban</button>
|
||||||
<button class="btn btn-sm" ng-if="userInfo.status === 'banned' || userInfo.status === 'removed'" ng-click="activateUser()"><i class="fas fa-check-circle"></i> Activate</button>
|
<button class="btn btn-sm" ng-if="userInfo.status === 'banned' || userInfo.status === 'removed'" ng-click="activateUser()"><i class="fas fa-check-circle"></i> Activate</button>
|
||||||
|
<button class="btn btn-sm" ng-if="!userInfo.isAdmin" ng-click="promoteUser()"><i class="fas fa-user-shield"></i> Promote to admin</button>
|
||||||
|
<button class="btn btn-sm text-danger" ng-if="userInfo.isAdmin" ng-click="demoteUser()"><i class="fas fa-user-minus"></i> Remove admin</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,6 +49,15 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-label">Created</div>
|
||||||
|
<div class="detail-value">{{userInfo.dateOfEntry | humanTime}}</div>
|
||||||
|
|
||||||
|
<div class="detail-label">Last connection</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
<span ng-if="userInfo.accessTokenDates.github">{{userInfo.accessTokenDates.github | humanTime}}</span>
|
||||||
|
<span class="text-muted" ng-if="!userInfo.accessTokenDates.github">never</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="detail-label">GitHub repos</div>
|
<div class="detail-label">GitHub repos</div>
|
||||||
<div class="detail-value">
|
<div class="detail-value">
|
||||||
{{userInfo.repositories.length}} repositories
|
{{userInfo.repositories.length}} repositories
|
||||||
@@ -122,86 +134,76 @@
|
|||||||
<span class="section-count">{{repositories.length}}</span>
|
<span class="section-count">{{repositories.length}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="w-100 dashboard-filter-row" aria-label="Repositories" accept-charset="UTF-8">
|
<div class="admin-summary">
|
||||||
<div class="search-wrap">
|
<span class="summary-total">{{repositories.length | number}}</span>
|
||||||
<input
|
<span class="summary-pill ok" ng-class="{active: filters.status.ready === false}" ng-click="filters.status.ready = !filters.status.ready" title="Toggle ready filter">Ready <span class="count">{{statusCountFor('ready') | number}}</span></span>
|
||||||
type="search"
|
<span class="summary-pill warn" ng-class="{active: filters.status.preparing === false}" ng-click="filters.status.preparing = !filters.status.preparing" title="Toggle preparing filter">Preparing <span class="count">{{statusCountFor('preparing') | number}}</span></span>
|
||||||
class="form-control"
|
<span class="summary-pill error" ng-class="{active: filters.status.error === false}" ng-click="filters.status.error = !filters.status.error" title="Toggle errored filter">Errored <span class="count">{{statusCountFor('error') | number}}</span></span>
|
||||||
aria-label="Search repositories"
|
<span class="summary-pill" ng-class="{active: filters.status.expired === false}" ng-click="filters.status.expired = !filters.status.expired" title="Toggle expired filter">Expired <span class="count">{{statusCountFor('expired') | number}}</span></span>
|
||||||
placeholder="Search repositories…"
|
<span class="summary-pill" ng-class="{active: filters.status.removed === false}" ng-click="filters.status.removed = !filters.status.removed" title="Toggle removed filter">Removed <span class="count">{{statusCountFor('removed') | number}}</span></span>
|
||||||
autocomplete="off"
|
</div>
|
||||||
ng-model="search"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-wrap" style="gap: 8px;">
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn dropdown-toggle" type="button" id="dropdownSort" data-toggle="dropdown">Sort</button>
|
|
||||||
<div class="dropdown-menu" aria-labelledby="dropdownSort">
|
|
||||||
<h6 class="dropdown-header">Sort by</h6>
|
|
||||||
<a class="dropdown-item" href="#" ng-click="orderBy = '-anonymizeDate'">
|
|
||||||
<i class="fas fa-check" ng-show="orderBy == '-anonymizeDate'"></i> Anonymize date
|
|
||||||
</a>
|
|
||||||
<a class="dropdown-item" href="#" ng-click="orderBy = 'repoId'">
|
|
||||||
<i class="fas fa-check" ng-show="orderBy == 'repoId'"></i> ID
|
|
||||||
</a>
|
|
||||||
<a class="dropdown-item" href="#" ng-click="orderBy = '-status'">
|
|
||||||
<i class="fas fa-check" ng-show="orderBy == '-status'"></i> Status
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dropdown">
|
<form class="w-100 admin-filter-toolbar" aria-label="Repositories" accept-charset="UTF-8">
|
||||||
<button class="btn dropdown-toggle" type="button" id="dropdownStatus" data-toggle="dropdown">Status</button>
|
<div class="admin-filter-row">
|
||||||
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
|
<div class="search-wrap">
|
||||||
<h6 class="dropdown-header">Filter by status</h6>
|
<input
|
||||||
<div class="form-check dropdown-item">
|
type="search"
|
||||||
<input class="form-check-input" type="checkbox" id="adminUserStatusReady" ng-model="filters.status.ready" />
|
class="form-control"
|
||||||
<label class="form-check-label" for="adminUserStatusReady">Ready</label>
|
aria-label="Search repositories"
|
||||||
</div>
|
placeholder="Search repoId, source repo, error message…"
|
||||||
<div class="form-check dropdown-item">
|
autocomplete="off"
|
||||||
<input class="form-check-input" type="checkbox" id="adminUserStatusExpired" ng-model="filters.status.expired" />
|
ng-model="search"
|
||||||
<label class="form-check-label" for="adminUserStatusExpired">Expired</label>
|
/>
|
||||||
</div>
|
|
||||||
<div class="form-check dropdown-item">
|
|
||||||
<input class="form-check-input" type="checkbox" id="adminUserStatusRemoved" ng-model="filters.status.removed" />
|
|
||||||
<label class="form-check-label" for="adminUserStatusRemoved">Removed</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="admin-filter-spacer"></span>
|
||||||
|
<button class="btn btn-sm" type="button" ng-click="exportCsv()" title="Export current view to CSV"><i class="fas fa-file-csv"></i> Export</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="paper-table paper-table-repos w-100" role="table" aria-label="Repositories">
|
<div class="bulk-bar" ng-if="selectedCount() > 0">
|
||||||
|
<span><strong>{{selectedCount()}}</strong> selected</span>
|
||||||
|
<button class="btn btn-sm" type="button" ng-click="bulkRefresh()"><i class="fas fa-sync"></i> Force refresh</button>
|
||||||
|
<button class="btn btn-sm text-danger" type="button" ng-click="bulkRemoveCache()"><i class="fas fa-broom"></i> Remove cache</button>
|
||||||
|
<button class="btn btn-sm" type="button" ng-click="clearSelection()">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paper-table paper-table-repos has-bulk w-100" role="table" aria-label="Repositories">
|
||||||
<div class="paper-table-head" role="row">
|
<div class="paper-table-head" role="row">
|
||||||
<div role="columnheader">Repository</div>
|
<div role="columnheader" style="width: 28px;">
|
||||||
<div role="columnheader">Status</div>
|
<input type="checkbox" ng-click="selectAllOnPage()" ng-checked="allSelected" aria-label="Select all on page" />
|
||||||
<div role="columnheader" class="num">Views</div>
|
</div>
|
||||||
<div role="columnheader">Anonymized</div>
|
<div role="columnheader"><span class="sortable" ng-class="{active: query.sort == 'source.repositoryName'}" ng-click="sortBy('source.repositoryName')">Repository <i class="fas" ng-class="sortIcon('source.repositoryName')"></i></span></div>
|
||||||
|
<div role="columnheader"><span class="sortable" ng-class="{active: query.sort == 'status'}" ng-click="sortBy('status')">Status <i class="fas" ng-class="sortIcon('status')"></i></span></div>
|
||||||
|
<div role="columnheader" class="num"><span class="sortable" ng-class="{active: query.sort == 'pageView'}" ng-click="sortBy('pageView')">Views <i class="fas" ng-class="sortIcon('pageView')"></i></span></div>
|
||||||
|
<div role="columnheader"><span class="sortable" ng-class="{active: query.sort == 'anonymizeDate'}" ng-click="sortBy('anonymizeDate')">Anonymized <i class="fas" ng-class="sortIcon('anonymizeDate')"></i></span></div>
|
||||||
<div role="columnheader" aria-label="Actions"></div>
|
<div role="columnheader" aria-label="Actions"></div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="paper-table-row"
|
class="paper-table-row"
|
||||||
role="row"
|
role="row"
|
||||||
ng-class="{'repo-inactive': repo.status == 'expired' || repo.status == 'removed', 'repo-error': repo.status == 'error'}"
|
ng-class="{'repo-inactive': repo.status == 'expired' || repo.status == 'removed', 'repo-error': repo.status == 'error', 'row-selected': selected[repo.repoId]}"
|
||||||
ng-repeat="repo in repositories | filter:repoFiler | orderBy:orderBy as filteredRepositories"
|
ng-repeat="repo in repositories | filter:repoFiler | orderBy:orderBy as filteredRepositories"
|
||||||
>
|
>
|
||||||
|
<div role="cell" style="width: 28px;">
|
||||||
|
<input type="checkbox" ng-model="selected[repo.repoId]" aria-label="Select repository" />
|
||||||
|
</div>
|
||||||
<div class="cell-anon" role="cell">
|
<div class="cell-anon" role="cell">
|
||||||
<span class="type-badge type-repo">Repo</span>
|
<span class="type-badge type-repo">Repo</span>
|
||||||
<div class="anon-text">
|
<div class="anon-text">
|
||||||
<a class="repo-name" ng-href="/r/{{repo.repoId}}" ng-bind="repo.repoId"></a>
|
<a class="repo-name" target="_blank" ng-href="/r/{{repo.repoId}}" ng-bind="repo.repoId"></a>
|
||||||
<div class="anon-sub">
|
<div class="anon-sub">
|
||||||
<a href="https://github.com/{{repo.source.fullName}}/" ng-bind="repo.source.fullName"></a><span ng-if="repo.options.update"> · <a href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.branch}}" ng-bind="repo.source.branch"></a></span><span ng-if="!repo.options.update"> · @<a href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.commit}}" ng-bind="repo.source.commit.substring(0, 8)"></a></span><span> · {{::repo.size.storage | humanFileSize}}</span><span> · {{::repo.options.terms.length | number}} terms</span>
|
<a href="https://github.com/{{repo.source.fullName}}/" ng-bind="repo.source.fullName"></a><span ng-if="repo.options.update"> · <a href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.branch}}" ng-bind="repo.source.branch"></a></span><span ng-if="!repo.options.update"> · @<a href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.commit}}" ng-bind="repo.source.commit.substring(0, 8)"></a></span><span ng-if="::repo.conference"> · <i class="fas fa-chalkboard-teacher"></i> {{repo.conference}}</span><span> · {{::repo.size.storage | humanFileSize}}</span><span> · {{::repo.options.terms.length | number}} terms</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell-status" role="cell">
|
<div class="cell-status" role="cell">
|
||||||
<span class="status-line">
|
<span class="status-line">
|
||||||
<span class="status-dot" ng-class="{'status-removed': repo.status == 'removed' || repo.status == 'expired', 'status-ready': repo.status == 'ready', 'status-error': repo.status == 'error'}"></span>
|
<span class="status-dot" ng-class="{'status-removed': repo.status == 'removed' || repo.status == 'expired', 'status-ready': repo.status == 'ready', 'status-error': repo.status == 'error', 'status-preparing': repo.status == 'preparing'}"></span>
|
||||||
<span ng-bind="repo.status | title"></span>
|
<span ng-bind="repo.status | title"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="status-sub status-sub-error" ng-if="repo.status == 'error' && repo.statusMessage" title="{{repo.statusMessage}}" ng-bind="repo.statusMessage"></span>
|
<span class="status-sub" ng-if="repo.statusMessage" title="{{repo.statusMessage}}" ng-bind="repo.statusMessage"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell-views num" role="cell" ng-bind="::repo.pageView | number"></div>
|
<div class="cell-views num" role="cell" ng-bind="::repo.pageView || 0 | number"></div>
|
||||||
<div class="cell-expires" role="cell" ng-bind="repo.anonymizeDate | humanTime"></div>
|
<div class="cell-expires" role="cell" ng-bind="repo.anonymizeDate | humanTime"></div>
|
||||||
<div class="cell-actions" role="cell">
|
<div class="cell-actions" role="cell">
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
@@ -212,9 +214,11 @@
|
|||||||
<a class="dropdown-item" href="/anonymize/{{repo.repoId}}"><i class="far fa-edit"></i> Edit</a>
|
<a class="dropdown-item" href="/anonymize/{{repo.repoId}}"><i class="far fa-edit"></i> Edit</a>
|
||||||
<a class="dropdown-item" href="/r/{{repo.repoId}}/"><i class="fa fa-eye"></i> View repo</a>
|
<a class="dropdown-item" href="/r/{{repo.repoId}}/"><i class="fa fa-eye"></i> View repo</a>
|
||||||
<a class="dropdown-item" href="/w/{{repo.repoId}}/" target="_self" ng-if="repo.options.page && repo.status == 'ready'"><i class="fas fa-globe"></i> View page</a>
|
<a class="dropdown-item" href="/w/{{repo.repoId}}/" target="_self" ng-if="repo.options.page && repo.status == 'ready'"><i class="fas fa-globe"></i> View page</a>
|
||||||
|
<a class="dropdown-item" href="#" ng-click="fetchGithubInfo(repo)"><i class="fab fa-github"></i> Live GitHub info</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a class="dropdown-item" href="#" ng-show="repo.status == 'ready' || repo.status == 'error'" ng-click="updateRepository(repo)"><i class="fas fa-sync"></i> Force update</a>
|
<a class="dropdown-item" href="#" ng-show="repo.status == 'ready' || repo.status == 'error'" ng-click="updateRepository(repo)"><i class="fas fa-sync"></i> Force update</a>
|
||||||
<a class="dropdown-item" href="#" ng-show="repo.status == 'removed'" ng-click="updateRepository(repo)"><i class="fas fa-check-circle"></i> Enable</a>
|
<a class="dropdown-item" href="#" ng-show="repo.status == 'removed'" ng-click="updateRepository(repo)"><i class="fas fa-check-circle"></i> Enable</a>
|
||||||
|
<a class="dropdown-item" href="#" ng-show="repo.statusMessage" ng-click="showStatusMessage(repo)"><i class="fas fa-exclamation-triangle"></i> View status message</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a class="dropdown-item" href="#" ng-click="removeCache(repo)"><i class="fas fa-broom"></i> Remove cache</a>
|
<a class="dropdown-item" href="#" ng-click="removeCache(repo)"><i class="fas fa-broom"></i> Remove cache</a>
|
||||||
<a class="dropdown-item text-danger" href="#" ng-show="repo.status == 'ready'" ng-click="removeRepository(repo)"><i class="fas fa-trash-alt"></i> Remove</a>
|
<a class="dropdown-item text-danger" href="#" ng-show="repo.status == 'ready'" ng-click="removeRepository(repo)"><i class="fas fa-trash-alt"></i> Remove</a>
|
||||||
@@ -224,7 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="paper-table-empty" ng-if="filteredRepositories.length == 0">
|
<div class="paper-table-empty" ng-if="filteredRepositories.length == 0">
|
||||||
<i class="fas fa-inbox"></i>
|
<i class="fas fa-inbox"></i>
|
||||||
<span>No repositories to display.</span>
|
<span>No repositories match the current filters.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<h1 class="paper-page-title">Users</h1>
|
<h1 class="paper-page-title">Users</h1>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
<nav class="admin-nav">
|
||||||
<a href="/admin/"><i class="fas fa-code-branch"></i> Repositories</a>
|
<a href="/admin/"><i class="fas fa-tachometer-alt"></i> Overview</a>
|
||||||
|
<a href="/admin/repositories"><i class="fas fa-code-branch"></i> Repositories</a>
|
||||||
<a href="/admin/users" class="active"><i class="fas fa-users"></i> Users</a>
|
<a href="/admin/users" class="active"><i class="fas fa-users"></i> Users</a>
|
||||||
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
<a href="/admin/conferences"><i class="fas fa-chalkboard-teacher"></i> Conferences</a>
|
||||||
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
<a href="/admin/queues"><i class="fas fa-tasks"></i> Queues</a>
|
||||||
|
|||||||
@@ -16,6 +16,31 @@
|
|||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="leftCol-search">
|
||||||
|
<div class="tree-search-box">
|
||||||
|
<i class="fas tree-search-icon" ng-class="fileSearchLoading ? 'fa-spinner fa-spin' : 'fa-search'"></i>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="tree-search-input"
|
||||||
|
placeholder="Search files"
|
||||||
|
ng-model="fileSearchQuery"
|
||||||
|
ng-model-options="{ debounce: 300 }"
|
||||||
|
ng-change="onFileSearchChange()"
|
||||||
|
aria-label="Search files"
|
||||||
|
/>
|
||||||
|
<kbd class="tree-search-kbd" ng-hide="fileSearchQuery">{{isMac ? '⌘' : 'Ctrl+'}}K</kbd>
|
||||||
|
<button
|
||||||
|
class="tree-search-clear"
|
||||||
|
ng-show="fileSearchQuery"
|
||||||
|
ng-click="fileSearchQuery = ''; onFileSearchChange()"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="leftCol-project-header">
|
||||||
|
<span class="project-name" ng-bind="repoId"></span>
|
||||||
|
<span class="project-file-count">{{fileCounts[''] || files.length}} files</span>
|
||||||
|
</div>
|
||||||
<div class="leftCol-body">
|
<div class="leftCol-body">
|
||||||
<div
|
<div
|
||||||
ng-if="options.truncatedFolders.length > 0"
|
ng-if="options.truncatedFolders.length > 0"
|
||||||
@@ -25,7 +50,7 @@
|
|||||||
<i class="fas fa-exclamation-triangle"></i>
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
{{ 'WARNINGS.repo_truncated' | translate }}
|
{{ 'WARNINGS.repo_truncated' | translate }}
|
||||||
</div>
|
</div>
|
||||||
<tree class="files" file="files"></tree>
|
<tree class="files" file="files" search-query="fileSearchQuery" search-results="fileSearchResults"></tree>
|
||||||
</div>
|
</div>
|
||||||
<div class="leftCol-foot">
|
<div class="leftCol-foot">
|
||||||
<span
|
<span
|
||||||
|
|||||||
+427
-82
@@ -452,11 +452,14 @@ angular
|
|||||||
$scope.userInfo;
|
$scope.userInfo;
|
||||||
$scope.repositories = [];
|
$scope.repositories = [];
|
||||||
$scope.search = "";
|
$scope.search = "";
|
||||||
|
$scope.selected = {};
|
||||||
|
$scope.allSelected = false;
|
||||||
|
|
||||||
const adminUserPrefsKey = "admin.user.filterPrefs";
|
const adminUserPrefsKey = "admin.user.filterPrefs";
|
||||||
const adminUserDefaults = {
|
const adminUserDefaults = {
|
||||||
filters: { status: { ready: true, expired: true, removed: true, error: true, preparing: true } },
|
filters: { status: { ready: true, expired: true, removed: true, error: true, preparing: true } },
|
||||||
orderBy: "-anonymizeDate",
|
sort: "anonymizeDate",
|
||||||
|
direction: "desc",
|
||||||
};
|
};
|
||||||
const savedAdminUserPrefs = loadFilterPrefs(adminUserPrefsKey) || {};
|
const savedAdminUserPrefs = loadFilterPrefs(adminUserPrefsKey) || {};
|
||||||
$scope.filters = {
|
$scope.filters = {
|
||||||
@@ -466,25 +469,49 @@ angular
|
|||||||
(savedAdminUserPrefs.filters && savedAdminUserPrefs.filters.status) || {}
|
(savedAdminUserPrefs.filters && savedAdminUserPrefs.filters.status) || {}
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
$scope.orderBy = savedAdminUserPrefs.orderBy || adminUserDefaults.orderBy;
|
$scope.query = {
|
||||||
|
sort: savedAdminUserPrefs.sort || adminUserDefaults.sort,
|
||||||
|
direction: savedAdminUserPrefs.direction || adminUserDefaults.direction,
|
||||||
|
};
|
||||||
|
$scope.orderBy = ($scope.query.direction === "asc" ? "" : "-") + $scope.query.sort;
|
||||||
|
|
||||||
$scope.$watch("orderBy", () => {
|
$scope.sortBy = (field) => {
|
||||||
|
if ($scope.query.sort === field) {
|
||||||
|
$scope.query.direction = $scope.query.direction === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
$scope.query.sort = field;
|
||||||
|
$scope.query.direction = "desc";
|
||||||
|
}
|
||||||
|
$scope.orderBy = ($scope.query.direction === "asc" ? "" : "-") + $scope.query.sort;
|
||||||
|
};
|
||||||
|
$scope.sortIcon = (field) =>
|
||||||
|
$scope.query.sort === field
|
||||||
|
? ($scope.query.direction === "asc" ? "fa-arrow-up" : "fa-arrow-down")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
$scope.$watch("query", () => {
|
||||||
saveFilterPrefs(adminUserPrefsKey, {
|
saveFilterPrefs(adminUserPrefsKey, {
|
||||||
filters: $scope.filters,
|
filters: $scope.filters,
|
||||||
orderBy: $scope.orderBy,
|
sort: $scope.query.sort,
|
||||||
|
direction: $scope.query.direction,
|
||||||
});
|
});
|
||||||
});
|
}, true);
|
||||||
$scope.$watch(
|
$scope.$watch(
|
||||||
"filters",
|
"filters",
|
||||||
() => {
|
() => {
|
||||||
saveFilterPrefs(adminUserPrefsKey, {
|
saveFilterPrefs(adminUserPrefsKey, {
|
||||||
filters: $scope.filters,
|
filters: $scope.filters,
|
||||||
orderBy: $scope.orderBy,
|
sort: $scope.query.sort,
|
||||||
|
direction: $scope.query.direction,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$scope.statusCountFor = (s) => {
|
||||||
|
return ($scope.repositories || []).filter((r) => r.status === s).length;
|
||||||
|
};
|
||||||
|
|
||||||
$scope.repoFiler = (repo) => {
|
$scope.repoFiler = (repo) => {
|
||||||
if ($scope.filters.status[repo.status] == false) return false;
|
if ($scope.filters.status[repo.status] == false) return false;
|
||||||
|
|
||||||
@@ -492,10 +519,84 @@ angular
|
|||||||
|
|
||||||
if (repo.source.fullName.indexOf($scope.search) > -1) return true;
|
if (repo.source.fullName.indexOf($scope.search) > -1) return true;
|
||||||
if (repo.repoId.indexOf($scope.search) > -1) return true;
|
if (repo.repoId.indexOf($scope.search) > -1) return true;
|
||||||
|
if (repo.statusMessage && repo.statusMessage.indexOf($scope.search) > -1) return true;
|
||||||
|
if (repo.conference && repo.conference.indexOf($scope.search) > -1) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// -------- selection / bulk --------
|
||||||
|
$scope.selectAllOnPage = () => {
|
||||||
|
$scope.allSelected = !$scope.allSelected;
|
||||||
|
($scope.filteredRepositories || $scope.repositories).forEach((r) => {
|
||||||
|
$scope.selected[r.repoId] = $scope.allSelected;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
$scope.selectedCount = () =>
|
||||||
|
Object.values($scope.selected || {}).filter(Boolean).length;
|
||||||
|
$scope.selectedRepos = () =>
|
||||||
|
$scope.repositories.filter((r) => $scope.selected[r.repoId]);
|
||||||
|
$scope.bulkRefresh = () => {
|
||||||
|
const repos = $scope.selectedRepos();
|
||||||
|
if (!repos.length) return;
|
||||||
|
if (!confirm(`Force refresh ${repos.length} repositories?`)) return;
|
||||||
|
repos.forEach((r) => $scope.updateRepository(r));
|
||||||
|
};
|
||||||
|
$scope.bulkRemoveCache = () => {
|
||||||
|
const repos = $scope.selectedRepos();
|
||||||
|
if (!repos.length) return;
|
||||||
|
if (!confirm(`Purge cache for ${repos.length} repositories?`)) return;
|
||||||
|
repos.forEach((r) => $scope.removeCache(r));
|
||||||
|
};
|
||||||
|
$scope.clearSelection = () => {
|
||||||
|
$scope.selected = {};
|
||||||
|
$scope.allSelected = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------- export --------
|
||||||
|
$scope.exportCsv = () => {
|
||||||
|
const filtered = ($scope.filteredRepositories || $scope.repositories);
|
||||||
|
const columns = ["repoId", "status", "statusMessage", "pageView", "anonymizeDate", "source.fullName", "conference", "size.storage"];
|
||||||
|
const header = columns.join(",");
|
||||||
|
const rows = filtered.map((r) =>
|
||||||
|
[r.repoId, r.status, r.statusMessage || "", r.pageView || 0, r.anonymizeDate || "", (r.source && r.source.fullName) || "", r.conference || "", (r.size && r.size.storage) || 0]
|
||||||
|
.map((v) => { const s = String(v == null ? "" : v); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; })
|
||||||
|
.join(",")
|
||||||
|
);
|
||||||
|
const blob = new Blob([header + "\n" + rows.join("\n")], { type: "text/csv" });
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = $routeParams.username + "-repositories.csv";
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.showStatusMessage = (repo) => {
|
||||||
|
const msg = repo.statusMessage || "(no message)";
|
||||||
|
window.prompt(`Status message for ${repo.repoId} (${repo.status}):`, msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.fetchGithubInfo = (repo) => {
|
||||||
|
const w = window.open("", "_blank");
|
||||||
|
if (w) w.document.write("<pre>Loading GitHub info for " + repo.repoId + "...</pre>");
|
||||||
|
$http.get("/api/admin/repos/" + repo.repoId + "/github").then(
|
||||||
|
(res) => {
|
||||||
|
if (w) {
|
||||||
|
w.document.open();
|
||||||
|
w.document.write(
|
||||||
|
"<pre style=\"font:13px monospace;padding:16px;white-space:pre-wrap\">" +
|
||||||
|
JSON.stringify(res.data, null, 2).replace(/[<>]/g, (c) => c === "<" ? "<" : ">") +
|
||||||
|
"</pre>"
|
||||||
|
);
|
||||||
|
w.document.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
const msg = err && err.data ? JSON.stringify(err.data, null, 2) : String(err);
|
||||||
|
if (w) w.document.body.innerHTML = "<pre style=\"color:#B42318;padding:16px\">" + msg + "</pre>";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function getUserRepositories(username) {
|
function getUserRepositories(username) {
|
||||||
$http.get("/api/admin/users/" + username + "/repos", {}).then(
|
$http.get("/api/admin/users/" + username + "/repos", {}).then(
|
||||||
(res) => {
|
(res) => {
|
||||||
@@ -530,6 +631,18 @@ angular
|
|||||||
.post(`/api/admin/users/${$routeParams.username}/activate`)
|
.post(`/api/admin/users/${$routeParams.username}/activate`)
|
||||||
.then(() => getUser($routeParams.username), (err) => console.error(err));
|
.then(() => getUser($routeParams.username), (err) => console.error(err));
|
||||||
};
|
};
|
||||||
|
$scope.promoteUser = () => {
|
||||||
|
if (!confirm(`Promote ${$routeParams.username} to admin?`)) return;
|
||||||
|
$http
|
||||||
|
.post(`/api/admin/users/${$routeParams.username}/promote`)
|
||||||
|
.then(() => getUser($routeParams.username), (err) => console.error(err));
|
||||||
|
};
|
||||||
|
$scope.demoteUser = () => {
|
||||||
|
if (!confirm(`Remove admin privileges from ${$routeParams.username}?`)) return;
|
||||||
|
$http
|
||||||
|
.post(`/api/admin/users/${$routeParams.username}/demote`)
|
||||||
|
.then(() => getUser($routeParams.username), (err) => console.error(err));
|
||||||
|
};
|
||||||
|
|
||||||
$scope.tokens = [];
|
$scope.tokens = [];
|
||||||
$scope.tokenForm = { name: "", plaintext: null };
|
$scope.tokenForm = { name: "", plaintext: null };
|
||||||
@@ -667,7 +780,9 @@ angular
|
|||||||
};
|
};
|
||||||
$scope.chips = [];
|
$scope.chips = [];
|
||||||
const recomputeChipsConf = () => {
|
const recomputeChipsConf = () => {
|
||||||
$scope.chips = [];
|
const out = [];
|
||||||
|
if ($scope.query.dateFrom || $scope.query.dateTo) out.push({ key: "dateRange", label: "Date", value: ($scope.query.dateFrom || "…") + " – " + ($scope.query.dateTo || "…") });
|
||||||
|
$scope.chips = out;
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.statusCountFor = (s) => {
|
$scope.statusCountFor = (s) => {
|
||||||
@@ -699,9 +814,13 @@ angular
|
|||||||
sort: "name",
|
sort: "name",
|
||||||
direction: "asc",
|
direction: "asc",
|
||||||
search: "",
|
search: "",
|
||||||
status: "",
|
|
||||||
dateFrom: "",
|
dateFrom: "",
|
||||||
dateTo: "",
|
dateTo: "",
|
||||||
|
ready: false,
|
||||||
|
expired: false,
|
||||||
|
removed: false,
|
||||||
|
error: true,
|
||||||
|
preparing: true,
|
||||||
};
|
};
|
||||||
const savedConfAdminPrefs = loadFilterPrefs(confAdminPrefsKey) || {};
|
const savedConfAdminPrefs = loadFilterPrefs(confAdminPrefsKey) || {};
|
||||||
$scope.query = Object.assign({}, confAdminDefaults, savedConfAdminPrefs, {
|
$scope.query = Object.assign({}, confAdminDefaults, savedConfAdminPrefs, {
|
||||||
@@ -709,6 +828,38 @@ angular
|
|||||||
search: "",
|
search: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// pre-fill filters from URL ?search=
|
||||||
|
const urlParams = $location.search();
|
||||||
|
if (urlParams.search) $scope.query.search = urlParams.search;
|
||||||
|
|
||||||
|
// -------- presets --------
|
||||||
|
const confPresetsKey = "admin.conferences.presets";
|
||||||
|
$scope.presets = JSON.parse(localStorage.getItem(confPresetsKey) || "[]");
|
||||||
|
$scope.savePreset = () => {
|
||||||
|
const name = window.prompt("Preset name:");
|
||||||
|
if (!name) return;
|
||||||
|
const snapshot = Object.assign({}, $scope.query);
|
||||||
|
delete snapshot.page;
|
||||||
|
$scope.presets = ($scope.presets || []).filter((p) => p.name !== name);
|
||||||
|
$scope.presets.push({ name, query: snapshot });
|
||||||
|
localStorage.setItem(confPresetsKey, JSON.stringify($scope.presets));
|
||||||
|
};
|
||||||
|
$scope.applyPreset = (p) => {
|
||||||
|
Object.assign($scope.query, p.query, { page: 1 });
|
||||||
|
};
|
||||||
|
$scope.deletePreset = (p) => {
|
||||||
|
$scope.presets = ($scope.presets || []).filter((x) => x.name !== p.name);
|
||||||
|
localStorage.setItem(confPresetsKey, JSON.stringify($scope.presets));
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.removeConference = (conference) => {
|
||||||
|
if (!confirm("Remove conference " + conference.conferenceID + "?")) return;
|
||||||
|
$http.delete("/api/admin/conferences/" + conference.conferenceID).then(
|
||||||
|
() => getConferences(),
|
||||||
|
(err) => console.error(err)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
$scope.exportCsv = () => {
|
$scope.exportCsv = () => {
|
||||||
const params = new URLSearchParams(
|
const params = new URLSearchParams(
|
||||||
Object.entries($scope.query).filter(([, v]) => v !== "" && v !== false && v != null)
|
Object.entries($scope.query).filter(([, v]) => v !== "" && v !== false && v != null)
|
||||||
@@ -727,7 +878,6 @@ angular
|
|||||||
$scope.totalPage = Math.ceil(res.data.total / $scope.query.limit);
|
$scope.totalPage = Math.ceil(res.data.total / $scope.query.limit);
|
||||||
$scope.conferences = res.data.results;
|
$scope.conferences = res.data.results;
|
||||||
$scope.statusCounts = res.data.statusCounts || [];
|
$scope.statusCounts = res.data.statusCounts || [];
|
||||||
$scope.$apply();
|
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
$scope.fetchError = (err && err.data && err.data.error) || "Failed to load conferences";
|
$scope.fetchError = (err && err.data && err.data.error) || "Failed to load conferences";
|
||||||
@@ -757,39 +907,24 @@ angular
|
|||||||
"$http",
|
"$http",
|
||||||
"$location",
|
"$location",
|
||||||
"$interval",
|
"$interval",
|
||||||
function ($scope, $http, $location, $interval) {
|
"$timeout",
|
||||||
|
function ($scope, $http, $location, $interval, $timeout) {
|
||||||
$scope.$watch("user.status", () => {
|
$scope.$watch("user.status", () => {
|
||||||
if ($scope.user == null) {
|
if ($scope.user == null) $location.url("/");
|
||||||
$location.url("/");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if ($scope.user == null) {
|
if ($scope.user == null) $location.url("/");
|
||||||
$location.url("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.downloadJobs = [];
|
$scope.queueList = [];
|
||||||
$scope.removeJobs = [];
|
$scope.jobs = [];
|
||||||
$scope.removeCaches = [];
|
$scope.selectedQueue = "download";
|
||||||
$scope.counts = { download: {}, remove: {}, cache: {} };
|
$scope.selectedStats = null;
|
||||||
|
$scope.range = "1h";
|
||||||
$scope.query = {
|
$scope.query = {
|
||||||
search: "",
|
search: "",
|
||||||
state: "",
|
state: "active",
|
||||||
autoRefresh: true,
|
autoRefresh: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.jobMatchesState = (job) => {
|
|
||||||
if (!$scope.query.state) return true;
|
|
||||||
const finished = !!job.finishedOn;
|
|
||||||
const failed = (job.stacktrace || []).length > 0 || job.failedReason;
|
|
||||||
const map = {
|
|
||||||
completed: finished && !failed,
|
|
||||||
failed: failed,
|
|
||||||
active: job.processedOn && !finished,
|
|
||||||
waiting: !job.processedOn,
|
|
||||||
};
|
|
||||||
return !!map[$scope.query.state];
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.jobProgressPct = (job) => {
|
$scope.jobProgressPct = (job) => {
|
||||||
if (job && job.progress && typeof job.progress === "object" && typeof job.progress.percent === "number") {
|
if (job && job.progress && typeof job.progress === "object" && typeof job.progress.percent === "number") {
|
||||||
return Math.max(0, Math.min(100, Math.round(job.progress.percent)));
|
return Math.max(0, Math.min(100, Math.round(job.progress.percent)));
|
||||||
@@ -800,31 +935,42 @@ angular
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.bulkRetryFailed = (queue) => {
|
$scope.jobDuration = (job) => {
|
||||||
if (!confirm(`Retry all failed jobs in the ${queue} queue?`)) return;
|
if (!job.processedOn) return "-";
|
||||||
$http.post(`/api/admin/queue/${queue}/retry-failed`).then(getQueues, (err) => console.error(err));
|
const end = job.finishedOn || Date.now();
|
||||||
|
const ms = end - job.processedOn;
|
||||||
|
if (ms < 1000) return ms + "ms";
|
||||||
|
return (ms / 1000).toFixed(1) + "s";
|
||||||
};
|
};
|
||||||
$scope.bulkDrain = (queue) => {
|
|
||||||
if (!confirm(`Drain (clear waiting+delayed) the ${queue} queue?`)) return;
|
$scope.selectQueue = (key) => {
|
||||||
$http.post(`/api/admin/queue/${queue}/drain`).then(getQueues, (err) => console.error(err));
|
$scope.selectedQueue = key;
|
||||||
|
getQueues();
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.setRange = (r) => {
|
||||||
|
$scope.range = r;
|
||||||
|
getQueues();
|
||||||
};
|
};
|
||||||
|
|
||||||
function getQueues() {
|
function getQueues() {
|
||||||
$http.get("/api/admin/queues", { params: $scope.query }).then(
|
const params = {
|
||||||
|
queue: $scope.selectedQueue,
|
||||||
|
state: $scope.query.state,
|
||||||
|
search: $scope.query.search,
|
||||||
|
};
|
||||||
|
$http.get("/api/admin/queues", { params }).then(
|
||||||
(res) => {
|
(res) => {
|
||||||
$scope.downloadJobs = res.data.downloadQueue;
|
$scope.queueList = res.data.queues || [];
|
||||||
$scope.removeJobs = res.data.removeQueue;
|
$scope.jobs = res.data.jobs || [];
|
||||||
$scope.removeCaches = res.data.cacheQueue;
|
$scope.selectedStats = $scope.queueList.find((q) => q.key === $scope.selectedQueue) || $scope.queueList[0] || null;
|
||||||
$scope.counts = res.data.counts || $scope.counts;
|
$timeout(drawChart, 0);
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => console.error(err)
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
getQueues();
|
getQueues();
|
||||||
|
|
||||||
// auto-refresh every 5 seconds while autoRefresh is on
|
|
||||||
const stop = $interval(() => {
|
const stop = $interval(() => {
|
||||||
if ($scope.query.autoRefresh) getQueues();
|
if ($scope.query.autoRefresh) getQueues();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -832,45 +978,126 @@ angular
|
|||||||
|
|
||||||
$scope.refreshNow = getQueues;
|
$scope.refreshNow = getQueues;
|
||||||
|
|
||||||
$scope.removeJob = function (queue, job) {
|
$scope.removeJob = (job) => {
|
||||||
$http
|
$http.delete(`/api/admin/queue/${$scope.selectedQueue}/${job.id}`).then(getQueues, (err) => console.error(err));
|
||||||
.delete(`/api/admin/queue/${queue}/${job.id}`, {
|
|
||||||
params: $scope.query,
|
|
||||||
})
|
|
||||||
.then(
|
|
||||||
(res) => {
|
|
||||||
getQueues();
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.retryJob = function (queue, job) {
|
$scope.retryJob = (job) => {
|
||||||
$http
|
$http.post(`/api/admin/queue/${$scope.selectedQueue}/${job.id}`).then(getQueues, (err) => console.error(err));
|
||||||
.post(`/api/admin/queue/${queue}/${job.id}`, {
|
};
|
||||||
params: $scope.query,
|
|
||||||
})
|
$scope.retryFailed = () => {
|
||||||
.then(
|
if (!confirm(`Retry all failed jobs in ${$scope.selectedQueue}?`)) return;
|
||||||
(res) => {
|
$http.post(`/api/admin/queue/${$scope.selectedQueue}/retry-failed`).then(getQueues, (err) => console.error(err));
|
||||||
getQueues();
|
};
|
||||||
},
|
|
||||||
(err) => {
|
$scope.drainSelected = () => {
|
||||||
console.error(err);
|
if (!confirm(`Drain the ${$scope.selectedQueue} queue?`)) return;
|
||||||
}
|
$http.post(`/api/admin/queue/${$scope.selectedQueue}/drain`).then(getQueues, (err) => console.error(err));
|
||||||
);
|
};
|
||||||
|
|
||||||
|
$scope.togglePause = () => {
|
||||||
|
const action = $scope.selectedStats && $scope.selectedStats.paused ? "resume" : "pause";
|
||||||
|
$http.post(`/api/admin/queue/${$scope.selectedQueue}/${action}`).then(getQueues, (err) => console.error(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.emptyQueue = () => {
|
||||||
|
if (!confirm(`Empty the ${$scope.selectedQueue} queue? This removes ALL jobs.`)) return;
|
||||||
|
$http.post(`/api/admin/queue/${$scope.selectedQueue}/empty`).then(getQueues, (err) => console.error(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.pauseAll = () => {
|
||||||
|
if (!confirm("Pause all queues?")) return;
|
||||||
|
$http.post("/api/admin/queues/pause-all").then(getQueues, (err) => console.error(err));
|
||||||
};
|
};
|
||||||
|
|
||||||
let searchClear = null;
|
let searchClear = null;
|
||||||
$scope.$watch(
|
$scope.$watch("query.search", () => {
|
||||||
"query.search",
|
clearTimeout(searchClear);
|
||||||
() => {
|
searchClear = setTimeout(getQueues, 350);
|
||||||
clearTimeout(searchClear);
|
});
|
||||||
searchClear = setTimeout(getQueues, 350);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$scope.$watch("query.state", getQueues);
|
$scope.$watch("query.state", getQueues);
|
||||||
|
|
||||||
|
function drawChart() {
|
||||||
|
const canvas = document.getElementById("q-throughput-chart");
|
||||||
|
if (!canvas || !$scope.selectedStats) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.parentElement.getBoundingClientRect();
|
||||||
|
const w = rect.width - 40;
|
||||||
|
const h = 160;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
canvas.style.width = w + "px";
|
||||||
|
canvas.style.height = h + "px";
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
const data = ($scope.selectedStats.throughput || []).slice().reverse();
|
||||||
|
if (data.length === 0) {
|
||||||
|
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--ink-muted").trim() || "#8A857C";
|
||||||
|
ctx.font = "12px var(--font-mono)";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText("No throughput data yet", w / 2, h / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangePoints = { "1h": 60, "6h": 120, "24h": 120, "7d": 120 };
|
||||||
|
const pts = data.slice(0, rangePoints[$scope.range] || 60);
|
||||||
|
const max = Math.max(1, ...pts);
|
||||||
|
const step = w / (pts.length - 1 || 1);
|
||||||
|
|
||||||
|
const isDark = document.body.classList.contains("dark-mode");
|
||||||
|
const lineColor = isDark ? "#A7B2FF" : "#3B4AD6";
|
||||||
|
const fillColor = isDark ? "rgba(167,178,255,0.12)" : "rgba(59,74,214,0.08)";
|
||||||
|
const gridColor = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)";
|
||||||
|
|
||||||
|
// grid
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const y = (h / 4) * i;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(w, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// area fill
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, h);
|
||||||
|
pts.forEach((v, i) => {
|
||||||
|
const x = i * step;
|
||||||
|
const y = h - (v / max) * (h - 10);
|
||||||
|
if (i === 0) ctx.lineTo(x, y);
|
||||||
|
else {
|
||||||
|
const px = (i - 1) * step;
|
||||||
|
const py = h - (pts[i - 1] / max) * (h - 10);
|
||||||
|
const cx = (px + x) / 2;
|
||||||
|
ctx.bezierCurveTo(cx, py, cx, y, x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.lineTo(w, h);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = fillColor;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// line
|
||||||
|
ctx.beginPath();
|
||||||
|
pts.forEach((v, i) => {
|
||||||
|
const x = i * step;
|
||||||
|
const y = h - (v / max) * (h - 10);
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else {
|
||||||
|
const px = (i - 1) * step;
|
||||||
|
const py = h - (pts[i - 1] / max) * (h - 10);
|
||||||
|
const cx = (px + x) / 2;
|
||||||
|
ctx.bezierCurveTo(cx, py, cx, y, x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.strokeStyle = lineColor;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.controller("errorsAdminController", [
|
.controller("errorsAdminController", [
|
||||||
@@ -1310,4 +1537,122 @@ angular
|
|||||||
$scope.$watch("query.sort", recompute);
|
$scope.$watch("query.sort", recompute);
|
||||||
$scope.$watch("query.group", recompute);
|
$scope.$watch("query.group", recompute);
|
||||||
},
|
},
|
||||||
|
])
|
||||||
|
.controller("overviewAdminController", [
|
||||||
|
"$scope",
|
||||||
|
"$http",
|
||||||
|
"$location",
|
||||||
|
"$interval",
|
||||||
|
function ($scope, $http, $location, $interval) {
|
||||||
|
$scope.Math = Math;
|
||||||
|
$scope.$watch("user.status", () => {
|
||||||
|
if ($scope.user == null) $location.url("/");
|
||||||
|
});
|
||||||
|
if ($scope.user == null) { $location.url("/"); return; }
|
||||||
|
|
||||||
|
$scope.data = null;
|
||||||
|
$scope.loading = true;
|
||||||
|
$scope.error = null;
|
||||||
|
$scope.range = "24h";
|
||||||
|
|
||||||
|
$scope.setRange = function (r) { $scope.range = r; };
|
||||||
|
|
||||||
|
function humanBytes(b) {
|
||||||
|
if (b == null) return "—";
|
||||||
|
var units = ["B","KB","MB","GB","TB"];
|
||||||
|
var i = 0;
|
||||||
|
var v = b;
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||||
|
return v.toFixed(i > 0 ? 1 : 0) + " " + units[i];
|
||||||
|
}
|
||||||
|
$scope.humanBytes = humanBytes;
|
||||||
|
|
||||||
|
function humanDuration(seconds) {
|
||||||
|
if (!seconds) return "—";
|
||||||
|
var d = Math.floor(seconds / 86400);
|
||||||
|
var h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
var m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (d > 0) return d + "d " + (h < 10 ? "0" : "") + h + "h";
|
||||||
|
if (h > 0) return h + "h " + (m < 10 ? "0" : "") + m + "m";
|
||||||
|
return m + "m";
|
||||||
|
}
|
||||||
|
$scope.humanDuration = humanDuration;
|
||||||
|
|
||||||
|
function humanNum(n) {
|
||||||
|
if (n == null) return "—";
|
||||||
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
$scope.humanNum = humanNum;
|
||||||
|
|
||||||
|
$scope.queueTotal = function (q) {
|
||||||
|
if (!q) return 0;
|
||||||
|
return (q.waiting || 0) + (q.active || 0) + (q.delayed || 0) + (q.failed || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.statusCount = function (status) {
|
||||||
|
if (!$scope.data || !$scope.data.repos) return 0;
|
||||||
|
var bd = $scope.data.repos.statusBreakdown || [];
|
||||||
|
for (var i = 0; i < bd.length; i++) {
|
||||||
|
if (bd[i]._id === status) return bd[i].count;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.barPct = function (status) {
|
||||||
|
var total = $scope.data && $scope.data.repos ? $scope.data.repos.total : 0;
|
||||||
|
if (!total) return 0;
|
||||||
|
var names = [status];
|
||||||
|
if (status === "expired") names.push("expiring");
|
||||||
|
if (status === "removed") names.push("removing");
|
||||||
|
if (status === "preparing") names.push("download");
|
||||||
|
var sum = 0;
|
||||||
|
names.forEach(function (n) { sum += $scope.statusCount(n); });
|
||||||
|
return Math.max(0.4, (sum / total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.errPct = function (key) {
|
||||||
|
if (!$scope.data || !$scope.data.errors) return 0;
|
||||||
|
var max = Math.max(
|
||||||
|
$scope.data.errors.severity.error,
|
||||||
|
$scope.data.errors.severity.warn,
|
||||||
|
$scope.data.errors.severity.info,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
return ($scope.data.errors.severity[key] / max) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
var historyMaxes = {};
|
||||||
|
$scope.historyBarH = function (d, field) {
|
||||||
|
if (!d || !historyMaxes[field]) return 0;
|
||||||
|
return Math.max(1, Math.round((d[field] / historyMaxes[field]) * 140));
|
||||||
|
};
|
||||||
|
$scope.historyLabel = function (d) {
|
||||||
|
if (!d || !d.date) return "";
|
||||||
|
var dt = new Date(d.date);
|
||||||
|
return (dt.getUTCMonth() + 1) + "/" + dt.getUTCDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
$http.get("/api/admin/overview").then(function (r) {
|
||||||
|
$scope.data = r.data;
|
||||||
|
$scope.loading = false;
|
||||||
|
$scope.error = null;
|
||||||
|
historyMaxes = {};
|
||||||
|
(r.data.history || []).forEach(function (d) {
|
||||||
|
["nbPageViews", "nbRepositories", "nbUsers"].forEach(function (k) {
|
||||||
|
if (!historyMaxes[k] || d[k] > historyMaxes[k]) historyMaxes[k] = d[k];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, function (err) {
|
||||||
|
$scope.loading = false;
|
||||||
|
$scope.error = (err.data && err.data.error) || "Failed to load overview";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
var stop = $interval(load, 30000);
|
||||||
|
$scope.$on("$destroy", function () { $interval.cancel(stop); });
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
+388
-81
@@ -113,6 +113,11 @@ angular
|
|||||||
reloadOnUrl: false,
|
reloadOnUrl: false,
|
||||||
})
|
})
|
||||||
.when("/admin/", {
|
.when("/admin/", {
|
||||||
|
templateUrl: "/partials/admin/overview.htm",
|
||||||
|
controller: "overviewAdminController",
|
||||||
|
title: "Admin · Overview – Anonymous GitHub",
|
||||||
|
})
|
||||||
|
.when("/admin/repositories", {
|
||||||
templateUrl: "/partials/admin/repositories.htm",
|
templateUrl: "/partials/admin/repositories.htm",
|
||||||
controller: "repositoriesAdminController",
|
controller: "repositoriesAdminController",
|
||||||
title: "Admin · Repositories – Anonymous GitHub",
|
title: "Admin · Repositories – Anonymous GitHub",
|
||||||
@@ -469,7 +474,7 @@ angular
|
|||||||
function () {
|
function () {
|
||||||
return {
|
return {
|
||||||
restrict: "E",
|
restrict: "E",
|
||||||
scope: { file: "=", parent: "@" },
|
scope: { file: "=", parent: "@", searchQuery: "=", searchResults: "=" },
|
||||||
controller: [
|
controller: [
|
||||||
"$element",
|
"$element",
|
||||||
"$scope",
|
"$scope",
|
||||||
@@ -491,26 +496,44 @@ angular
|
|||||||
const toArray = function (arr) {
|
const toArray = function (arr) {
|
||||||
const output = [];
|
const output = [];
|
||||||
const keys = { "": { child: output } };
|
const keys = { "": { child: output } };
|
||||||
|
function ensurePath(path) {
|
||||||
|
if (keys[path]) return;
|
||||||
|
const segments = path.split("/");
|
||||||
|
let acc = "";
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const parent = acc;
|
||||||
|
acc = acc ? acc + "/" + segments[i] : segments[i];
|
||||||
|
if (!keys[acc]) {
|
||||||
|
const dir = { name: segments[i], child: [] };
|
||||||
|
keys[acc] = dir;
|
||||||
|
keys[parent].child.push(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for (let file of arr) {
|
for (let file of arr) {
|
||||||
let current = keys[file.path].child;
|
if (file.path && !keys[file.path]) {
|
||||||
|
ensurePath(file.path);
|
||||||
|
}
|
||||||
|
let current = keys[file.path || ""].child;
|
||||||
let fPath = `${file.path}/${file.name}`;
|
let fPath = `${file.path}/${file.name}`;
|
||||||
if (fPath.startsWith("/")) {
|
if (fPath.startsWith("/")) {
|
||||||
fPath = fPath.substring(1);
|
fPath = fPath.substring(1);
|
||||||
}
|
}
|
||||||
if (file.size != null) {
|
if (file.size != null) {
|
||||||
// it is a file
|
|
||||||
current.push({
|
current.push({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
sha: file.sha,
|
sha: file.sha,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const dir = {
|
if (!keys[fPath]) {
|
||||||
name: file.name,
|
const dir = {
|
||||||
child: [],
|
name: file.name,
|
||||||
};
|
child: [],
|
||||||
keys[fPath] = dir;
|
};
|
||||||
current.push(dir);
|
keys[fPath] = dir;
|
||||||
|
current.push(dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
@@ -520,7 +543,7 @@ angular
|
|||||||
const f1d = !!f1.child;
|
const f1d = !!f1.child;
|
||||||
const f2d = !!f2.child;
|
const f2d = !!f2.child;
|
||||||
if (f1d && f2d) {
|
if (f1d && f2d) {
|
||||||
return f1.name - f2.name;
|
return f1.name.localeCompare(f2.name);
|
||||||
}
|
}
|
||||||
if (f1d) {
|
if (f1d) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -528,9 +551,18 @@ angular
|
|||||||
if (f2d) {
|
if (f2d) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
return f1.name - f2.name;
|
return f1.name.localeCompare(f2.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getFileCount(folderPath) {
|
||||||
|
const counts = $scope.$parent.fileCounts;
|
||||||
|
if (!counts) return 0;
|
||||||
|
const normalized = folderPath.startsWith("/")
|
||||||
|
? folderPath.substring(1)
|
||||||
|
: folderPath;
|
||||||
|
return counts[normalized] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
function isTruncated(folderPath) {
|
function isTruncated(folderPath) {
|
||||||
const truncated =
|
const truncated =
|
||||||
($scope.$parent.options &&
|
($scope.$parent.options &&
|
||||||
@@ -543,40 +575,82 @@ angular
|
|||||||
return truncated.indexOf(normalized) !== -1;
|
return truncated.indexOf(normalized) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generate(current, parentPath) {
|
function escapeHtml(str) {
|
||||||
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchFilter() {
|
||||||
|
const results = $scope.searchResults;
|
||||||
|
if (!results || !results.length) return null;
|
||||||
|
const matchPaths = new Set();
|
||||||
|
const matchFolders = new Set();
|
||||||
|
for (const f of results) {
|
||||||
|
const full = f.path ? `${f.path}/${f.name}` : f.name;
|
||||||
|
matchPaths.add(full);
|
||||||
|
// Also collect all ancestor folders
|
||||||
|
if (f.path) {
|
||||||
|
const segments = f.path.split("/").filter(Boolean);
|
||||||
|
let acc = "";
|
||||||
|
for (const seg of segments) {
|
||||||
|
acc = acc ? `${acc}/${seg}` : seg;
|
||||||
|
matchFolders.add(acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { paths: matchPaths, folders: matchFolders };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeMatchesFilter(node, parentPath, filterSet) {
|
||||||
|
if (!filterSet) return true;
|
||||||
|
const path = parentPath
|
||||||
|
? `${parentPath}/${node.name}`
|
||||||
|
: node.name;
|
||||||
|
if (!node.child) {
|
||||||
|
return filterSet.paths.has(path);
|
||||||
|
}
|
||||||
|
// Show folder if it's an ancestor of a match or contains matches
|
||||||
|
if (filterSet.folders.has(path)) return true;
|
||||||
|
return node.child.some((c) =>
|
||||||
|
nodeMatchesFilter(c, path, filterSet)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate(current, parentPath, filterSet) {
|
||||||
if (!current) return "";
|
if (!current) return "";
|
||||||
current = current.sort(sortFiles);
|
current = current.sort(sortFiles);
|
||||||
const afiles = current;
|
|
||||||
let output = "<ul>";
|
let output = "<ul>";
|
||||||
for (let f of afiles) {
|
for (let f of current) {
|
||||||
|
if (filterSet && !nodeMatchesFilter(f, parentPath ? parentPath.substring(1) : "", filterSet)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let dir = !!f.child;
|
let dir = !!f.child;
|
||||||
let name = f.name;
|
let name = f.name;
|
||||||
let size = f.size;
|
let size = f.size;
|
||||||
|
let collapsed = f;
|
||||||
if (dir) {
|
if (dir) {
|
||||||
let test = name;
|
let test = name;
|
||||||
current = f.child;
|
let inner = f.child;
|
||||||
while (current && current.length == 1) {
|
while (inner && inner.length == 1) {
|
||||||
test += "/" + current[0].name;
|
test += "/" + inner[0].name;
|
||||||
size = current[0].size;
|
size = inner[0].size;
|
||||||
current = current[0].child;
|
inner = inner[0].child;
|
||||||
}
|
}
|
||||||
name = test;
|
name = test;
|
||||||
|
collapsed = inner ? { child: inner } : f;
|
||||||
if (size != null && size >= 0) {
|
if (size != null && size >= 0) {
|
||||||
dir = false;
|
dir = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (size != null) {
|
const sizeTitle = size != null ? `Size: ${humanFileSize(size || 0)}` : "";
|
||||||
size = `Size: ${humanFileSize(size || 0)}`;
|
|
||||||
} else {
|
|
||||||
size = "";
|
|
||||||
}
|
|
||||||
const path = `${parentPath}/${name}`;
|
const path = `${parentPath}/${name}`;
|
||||||
|
const fileCount = dir ? getFileCount(path) : 0;
|
||||||
|
|
||||||
|
const isOpen = filterSet ? ($scope.opens[path] !== false) : $scope.opens[path];
|
||||||
const cssClasses = ["file"];
|
const cssClasses = ["file"];
|
||||||
if (dir) {
|
if (dir) {
|
||||||
cssClasses.push("folder");
|
cssClasses.push("folder");
|
||||||
}
|
}
|
||||||
if ($scope.opens[path]) {
|
if (isOpen) {
|
||||||
cssClasses.push("open");
|
cssClasses.push("open");
|
||||||
}
|
}
|
||||||
if ($scope.isActive(path)) {
|
if ($scope.isActive(path)) {
|
||||||
@@ -589,48 +663,54 @@ angular
|
|||||||
|
|
||||||
output += `<li class="${cssClasses.join(
|
output += `<li class="${cssClasses.join(
|
||||||
" "
|
" "
|
||||||
)}" ng-class="{active: isActive('${path}'), open: opens['${path}']}" title="${size}">`;
|
)}" ng-class="{active: isActive('${path}'), open: ${filterSet ? "opens['" + path + "'] !== false" : "opens['" + path + "']"}}" title="${escapeHtml(sizeTitle)}">`;
|
||||||
if (dir) {
|
if (dir) {
|
||||||
output += `<a ng-click="openFolder('${path}', $event)">${name}</a>`;
|
output += `<a ng-click="openFolder('${path}', $event)"><span class="tree-toggle"></span><span class="tree-icon-folder"></span><span class="tree-name">${escapeHtml(name)}</span>`;
|
||||||
|
if (truncated) {
|
||||||
|
output += `<span class="truncated-warning" title="{{ 'WARNINGS.folder_truncated' | translate }}"><i class="fas fa-exclamation-triangle"></i></span>`;
|
||||||
|
}
|
||||||
|
if (fileCount > 0) {
|
||||||
|
output += `<span class="tree-count">${fileCount}</span>`;
|
||||||
|
}
|
||||||
|
output += `</a>`;
|
||||||
} else {
|
} else {
|
||||||
|
const needsSpacer = parentPath !== "";
|
||||||
output += `<a href='/r/${$scope.repoId}${encodePathForUrl(
|
output += `<a href='/r/${$scope.repoId}${encodePathForUrl(
|
||||||
path
|
path
|
||||||
)}'>${name}</a>`;
|
)}'>${needsSpacer ? '<span class="tree-spacer"></span>' : ''}<span class="tree-icon-file"></span><span class="tree-name">${escapeHtml(name)}</span></a>`;
|
||||||
}
|
}
|
||||||
if (truncated) {
|
if (isOpen && collapsed.child) {
|
||||||
output += `<span class="truncated-warning" title="{{ 'WARNINGS.folder_truncated' | translate }}"><i class="fas fa-exclamation-triangle"></i></span>`;
|
const children = collapsed.child;
|
||||||
}
|
if (children.length > 1) {
|
||||||
if ($scope.opens[path] && f.child) {
|
output += generate(children, path, filterSet);
|
||||||
if (f.child.length > 1) {
|
|
||||||
output += generate(f.child, path);
|
|
||||||
} else if (dir) {
|
} else if (dir) {
|
||||||
current = f.child;
|
let inner = children;
|
||||||
while (current && current.length == 1) {
|
while (inner && inner.length == 1) {
|
||||||
current = current[0].child;
|
inner = inner[0].child;
|
||||||
}
|
}
|
||||||
output += generate(current, path);
|
output += generate(inner, path, filterSet);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// output += generate(f.child, parentPath + "/" + f.name);
|
output += "</li>";
|
||||||
output + "</li>";
|
|
||||||
}
|
}
|
||||||
return output + "</ul>";
|
return output + "</ul>";
|
||||||
}
|
}
|
||||||
|
|
||||||
function display() {
|
function display() {
|
||||||
$element.html("");
|
$element.html("");
|
||||||
const output = generate(toArray($scope.file).sort(sortFiles), "");
|
const filterSet = $scope.searchQuery ? buildSearchFilter() : null;
|
||||||
|
let output;
|
||||||
|
if (filterSet !== null && filterSet.paths.size === 0) {
|
||||||
|
output = '<div class="tree-search-empty">No files found</div>';
|
||||||
|
} else {
|
||||||
|
output = generate(toArray($scope.file).sort(sortFiles), "", filterSet);
|
||||||
|
}
|
||||||
$compile(output)($scope, (clone) => {
|
$compile(output)($scope, (clone) => {
|
||||||
$element.append(clone);
|
$element.append(clone);
|
||||||
|
restoreFocus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// #496 — expand folders whose children are already loaded so
|
|
||||||
// reviewers see the whole tree without clicking through. Skip
|
|
||||||
// folders with empty children to avoid emitting an empty <ul>
|
|
||||||
// that breaks the click-time lazy-load (#496-followup): the
|
|
||||||
// openFolder handler used to detect "needs to load" by looking
|
|
||||||
// at the absence of a sibling node, but a pre-expanded empty
|
|
||||||
// <ul> is a non-null sibling and silently suppressed the fetch.
|
|
||||||
function expandAllFolders(nodes, parentPath) {
|
function expandAllFolders(nodes, parentPath) {
|
||||||
if (!nodes) return;
|
if (!nodes) return;
|
||||||
for (const f of nodes) {
|
for (const f of nodes) {
|
||||||
@@ -656,24 +736,148 @@ angular
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$scope.$watch("searchResults", (newVal, oldVal) => {
|
||||||
|
if (newVal === oldVal) return;
|
||||||
|
if ($scope.file && $scope.file.length) {
|
||||||
|
display();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.$watch("searchQuery", (newVal, oldVal) => {
|
||||||
|
if (newVal === oldVal) return;
|
||||||
|
if (!newVal && $scope.file && $scope.file.length) {
|
||||||
|
display();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$scope.isActive = function (name) {
|
$scope.isActive = function (name) {
|
||||||
return $routeParams.path == name.substring(1);
|
return $routeParams.path == name.substring(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.openFolder = async function (folder, event) {
|
$scope.openFolder = function (folder, event) {
|
||||||
$scope.opens[folder] = !$scope.opens[folder];
|
var currentlyOpen = $scope.opens[folder];
|
||||||
const sib = event.srcElement.nextSibling;
|
if (currentlyOpen === undefined && $scope.searchQuery) {
|
||||||
// Lazy-load when there's no sibling (folder never expanded) or
|
currentlyOpen = true;
|
||||||
// when the sibling is an empty <ul> from a pre-expanded folder
|
}
|
||||||
// whose children weren't fetched yet (#496-followup).
|
$scope.opens[folder] = !currentlyOpen;
|
||||||
|
const li = event.target.closest("li");
|
||||||
|
const childUl = li ? li.querySelector(":scope > ul") : null;
|
||||||
const needsLoad =
|
const needsLoad =
|
||||||
sib == null ||
|
childUl == null ||
|
||||||
(sib.tagName === "UL" && sib.children.length === 0);
|
childUl.children.length === 0;
|
||||||
if (needsLoad) {
|
if (needsLoad) {
|
||||||
await $scope.$parent.getFiles(folder.substring(1));
|
$scope.$parent.getFiles(folder.substring(1));
|
||||||
$scope.$apply();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var focusedPath = $routeParams.path ? "/" + $routeParams.path : null;
|
||||||
|
|
||||||
|
function getVisibleLinks() {
|
||||||
|
return Array.from($element[0].querySelectorAll("li > a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFocusedLink() {
|
||||||
|
return $element[0].querySelector("a.tree-focused");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkPath(link) {
|
||||||
|
if (!link) return null;
|
||||||
|
var href = link.getAttribute("href");
|
||||||
|
if (href) {
|
||||||
|
var prefix = "/r/" + $scope.repoId;
|
||||||
|
return href.indexOf(prefix) === 0 ? decodeURIComponent(href.substring(prefix.length)) : null;
|
||||||
|
}
|
||||||
|
var onclick = link.getAttribute("ng-click");
|
||||||
|
if (onclick) {
|
||||||
|
var m = onclick.match(/openFolder\('([^']+)'/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLinkByPath(path) {
|
||||||
|
if (!path) return null;
|
||||||
|
var links = getVisibleLinks();
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
if (getLinkPath(links[i]) === path) return links[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFocus(link) {
|
||||||
|
var prev = getFocusedLink();
|
||||||
|
if (prev) prev.classList.remove("tree-focused");
|
||||||
|
if (link) {
|
||||||
|
link.classList.add("tree-focused");
|
||||||
|
link.scrollIntoView({ block: "nearest" });
|
||||||
|
focusedPath = getLinkPath(link);
|
||||||
|
} else {
|
||||||
|
focusedPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFocus() {
|
||||||
|
if (!focusedPath) return;
|
||||||
|
var link = findLinkByPath(focusedPath);
|
||||||
|
if (link) {
|
||||||
|
link.classList.add("tree-focused");
|
||||||
|
$element[0].focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$element[0].setAttribute("tabindex", "0");
|
||||||
|
|
||||||
|
$element[0].addEventListener("keydown", function (e) {
|
||||||
|
var links = getVisibleLinks();
|
||||||
|
if (!links.length) return;
|
||||||
|
var focused = getFocusedLink();
|
||||||
|
var idx = focused ? links.indexOf(focused) : -1;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
var next = idx < links.length - 1 ? idx + 1 : 0;
|
||||||
|
setFocus(links[next]);
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
var prev = idx > 0 ? idx - 1 : links.length - 1;
|
||||||
|
setFocus(links[prev]);
|
||||||
|
} else if (e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!focused) return;
|
||||||
|
var li = focused.closest("li");
|
||||||
|
if (li && li.classList.contains("folder")) {
|
||||||
|
if (!li.classList.contains("open")) {
|
||||||
|
focused.click();
|
||||||
|
} else {
|
||||||
|
var childLink = li.querySelector(":scope > ul > li > a");
|
||||||
|
if (childLink) setFocus(childLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!focused) return;
|
||||||
|
var li = focused.closest("li");
|
||||||
|
if (li && li.classList.contains("folder") && li.classList.contains("open")) {
|
||||||
|
focused.click();
|
||||||
|
} else {
|
||||||
|
var parentLi = li && li.parentElement ? li.parentElement.closest("li.folder") : null;
|
||||||
|
if (parentLi) {
|
||||||
|
var parentLink = parentLi.querySelector(":scope > a");
|
||||||
|
if (parentLink) setFocus(parentLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (focused) focused.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$element[0].addEventListener("click", function (e) {
|
||||||
|
var link = e.target.closest("a");
|
||||||
|
if (link && $element[0].contains(link)) {
|
||||||
|
setFocus(link);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -2157,9 +2361,100 @@ angular
|
|||||||
"$location",
|
"$location",
|
||||||
"$routeParams",
|
"$routeParams",
|
||||||
"$sce",
|
"$sce",
|
||||||
|
"$q",
|
||||||
"PDFViewerService",
|
"PDFViewerService",
|
||||||
function ($scope, $http, $location, $routeParams, $sce, PDFViewerService) {
|
function ($scope, $http, $location, $routeParams, $sce, $q, PDFViewerService) {
|
||||||
$scope.files = [];
|
$scope.files = [];
|
||||||
|
$scope.isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
|
||||||
|
$scope.fileSearchQuery = "";
|
||||||
|
$scope.fileSearchResults = null;
|
||||||
|
$scope.fileSearchLoading = false;
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
var input = document.querySelector(".tree-search-input");
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var searchCanceller = null;
|
||||||
|
$scope.onFileSearchChange = function () {
|
||||||
|
// Cancel any in-flight search request
|
||||||
|
if (searchCanceller) {
|
||||||
|
searchCanceller.resolve();
|
||||||
|
searchCanceller = null;
|
||||||
|
}
|
||||||
|
const query = $scope.fileSearchQuery;
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
$scope.fileSearchResults = null;
|
||||||
|
$scope.fileSearchLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$scope.fileSearchLoading = true;
|
||||||
|
searchCanceller = $q.defer();
|
||||||
|
$http.get(
|
||||||
|
`/api/repo/${$scope.repoId}/files/search?q=${encodeURIComponent(query)}`,
|
||||||
|
{ timeout: searchCanceller.promise }
|
||||||
|
).then(function (res) {
|
||||||
|
searchCanceller = null;
|
||||||
|
$scope.fileSearchLoading = false;
|
||||||
|
// Merge search results into $scope.files so the tree can render them.
|
||||||
|
// Ancestor folders must appear before their children for toArray() to work.
|
||||||
|
var existing = {};
|
||||||
|
$scope.files.forEach(function(f) {
|
||||||
|
existing[(f.path || "") + "/" + f.name] = true;
|
||||||
|
});
|
||||||
|
// First pass: collect ancestor folders (shallow to deep)
|
||||||
|
var foldersToAdd = [];
|
||||||
|
var folderSeen = {};
|
||||||
|
for (var i = 0; i < res.data.length; i++) {
|
||||||
|
var f = res.data[i];
|
||||||
|
if (f.path) {
|
||||||
|
var segments = f.path.split("/");
|
||||||
|
var acc = "";
|
||||||
|
for (var j = 0; j < segments.length; j++) {
|
||||||
|
var parent = acc;
|
||||||
|
acc = acc ? acc + "/" + segments[j] : segments[j];
|
||||||
|
var folderKey = parent + "/" + segments[j];
|
||||||
|
if (!existing[folderKey] && !folderSeen[folderKey]) {
|
||||||
|
folderSeen[folderKey] = true;
|
||||||
|
foldersToAdd.push({ name: segments[j], path: parent });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort folders by depth (shallow first)
|
||||||
|
foldersToAdd.sort(function(a, b) {
|
||||||
|
return (a.path || "").split("/").length - (b.path || "").split("/").length;
|
||||||
|
});
|
||||||
|
// Add folders first, then files
|
||||||
|
if (foldersToAdd.length > 0) {
|
||||||
|
$scope.files.push.apply($scope.files, foldersToAdd);
|
||||||
|
}
|
||||||
|
var filesToAdd = [];
|
||||||
|
for (var k = 0; k < res.data.length; k++) {
|
||||||
|
var rf = res.data[k];
|
||||||
|
var key = (rf.path || "") + "/" + rf.name;
|
||||||
|
if (!existing[key] && rf.size != null) {
|
||||||
|
filesToAdd.push(rf);
|
||||||
|
existing[key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filesToAdd.length > 0) {
|
||||||
|
$scope.files.push.apply($scope.files, filesToAdd);
|
||||||
|
}
|
||||||
|
$scope.fileSearchResults = res.data;
|
||||||
|
}, function () {
|
||||||
|
// Only clear loading if this wasn't a cancellation
|
||||||
|
if (!searchCanceller) {
|
||||||
|
$scope.fileSearchLoading = false;
|
||||||
|
$scope.fileSearchResults = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
const extensionModes = {
|
const extensionModes = {
|
||||||
yml: "yaml",
|
yml: "yaml",
|
||||||
txt: "text",
|
txt: "text",
|
||||||
@@ -2270,21 +2565,30 @@ angular
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$scope.getFiles = async function (path) {
|
$scope.fileCounts = null;
|
||||||
try {
|
$scope.getFiles = function (path) {
|
||||||
const res = await $http.get(
|
return $http.get(
|
||||||
`/api/repo/${$scope.repoId}/files/?path=${encodeURIComponent(path)}&v=${$scope.options.lastUpdateDate}`
|
`/api/repo/${$scope.repoId}/files/?path=${encodeURIComponent(path)}&v=${$scope.options.lastUpdateDate}`
|
||||||
);
|
).then(function (res) {
|
||||||
const normalized = path || "";
|
const normalized = path || "";
|
||||||
$scope.files = $scope.files.filter((f) => f.path !== normalized);
|
$scope.files = $scope.files.filter((f) => f.path !== normalized);
|
||||||
$scope.files.push(...res.data);
|
$scope.files.push(...res.data);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err) {
|
}, function (err) {
|
||||||
$scope.type = "error";
|
$scope.type = "error";
|
||||||
$scope.content = (err && err.data && err.data.error) || "unknown_error";
|
$scope.content = (err && err.data && err.data.error) || "unknown_error";
|
||||||
$scope.files = [];
|
$scope.files = [];
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
function fetchFileCounts() {
|
||||||
|
$http.get(
|
||||||
|
`/api/repo/${$scope.repoId}/files/counts`
|
||||||
|
).then(function (res) {
|
||||||
|
$scope.fileCounts = res.data;
|
||||||
|
}, function () {
|
||||||
|
$scope.fileCounts = {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getSelectedFile() {
|
function getSelectedFile() {
|
||||||
return $scope.files.filter(
|
return $scope.files.filter(
|
||||||
@@ -2592,25 +2896,28 @@ angular
|
|||||||
$scope.filePath = $routeParams.path || "";
|
$scope.filePath = $routeParams.path || "";
|
||||||
$scope.paths = $scope.filePath.split("/");
|
$scope.paths = $scope.filePath.split("/");
|
||||||
|
|
||||||
getOptions(async (options) => {
|
getOptions(function (options) {
|
||||||
|
fetchFileCounts();
|
||||||
|
var chain = $q.resolve();
|
||||||
for (let i = 0; i < $scope.paths.length; i++) {
|
for (let i = 0; i < $scope.paths.length; i++) {
|
||||||
const path = i > 0 ? $scope.paths.slice(0, i).join("/") : "";
|
const path = i > 0 ? $scope.paths.slice(0, i).join("/") : "";
|
||||||
await $scope.getFiles(path);
|
chain = chain.then(function () {
|
||||||
if ($scope.type === "error") {
|
return $scope.getFiles(path);
|
||||||
$scope.$apply();
|
}).then(function () {
|
||||||
return;
|
if ($scope.type === "error") {
|
||||||
}
|
return $q.reject("error");
|
||||||
}
|
}
|
||||||
if ($scope.files.length == 1 && $scope.files[0].name == "") {
|
|
||||||
$scope.files = [];
|
|
||||||
$scope.type = "empty";
|
|
||||||
$scope.$apply();
|
|
||||||
} else {
|
|
||||||
$scope.$apply(() => {
|
|
||||||
selectFile();
|
|
||||||
updateContent();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
chain.then(function () {
|
||||||
|
if ($scope.files.length == 1 && $scope.files[0].name == "") {
|
||||||
|
$scope.files = [];
|
||||||
|
$scope.type = "empty";
|
||||||
|
} else {
|
||||||
|
selectFile();
|
||||||
|
updateContent();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+102
-102
File diff suppressed because one or more lines are too long
@@ -119,6 +119,7 @@ export function startWorker() {
|
|||||||
concurrency: 5,
|
concurrency: 5,
|
||||||
connection,
|
connection,
|
||||||
autorun: true,
|
autorun: true,
|
||||||
|
metrics: { maxDataPoints: 120 },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
cacheWorker.on("completed", async (job) => {
|
cacheWorker.on("completed", async (job) => {
|
||||||
@@ -131,6 +132,7 @@ export function startWorker() {
|
|||||||
concurrency: 5,
|
concurrency: 5,
|
||||||
connection,
|
connection,
|
||||||
autorun: true,
|
autorun: true,
|
||||||
|
metrics: { maxDataPoints: 120 },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
removeWorker.on("completed", async (job) => {
|
removeWorker.on("completed", async (job) => {
|
||||||
@@ -144,6 +146,7 @@ export function startWorker() {
|
|||||||
concurrency: 3,
|
concurrency: 3,
|
||||||
connection,
|
connection,
|
||||||
autorun: true,
|
autorun: true,
|
||||||
|
metrics: { maxDataPoints: 120 },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (!downloadWorker.isRunning()) downloadWorker.run();
|
if (!downloadWorker.isRunning()) downloadWorker.run();
|
||||||
|
|||||||
+338
-23
@@ -1,3 +1,5 @@
|
|||||||
|
import * as os from "os";
|
||||||
|
import { execSync } from "child_process";
|
||||||
import { Queue, JobType } from "bullmq";
|
import { Queue, JobType } from "bullmq";
|
||||||
import * as express from "express";
|
import * as express from "express";
|
||||||
import AnonymousError from "../../core/AnonymousError";
|
import AnonymousError from "../../core/AnonymousError";
|
||||||
@@ -199,21 +201,100 @@ router.post("/queue/:name/drain", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/queue/:name/pause", async (req, res) => {
|
||||||
|
const queue = pickQueue(req.params.name);
|
||||||
|
if (!queue) return res.status(404).json({ error: "queue_not_found" });
|
||||||
|
try {
|
||||||
|
await queue.pause();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/queue/:name/resume", async (req, res) => {
|
||||||
|
const queue = pickQueue(req.params.name);
|
||||||
|
if (!queue) return res.status(404).json({ error: "queue_not_found" });
|
||||||
|
try {
|
||||||
|
await queue.resume();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/queue/:name/empty", async (req, res) => {
|
||||||
|
const queue = pickQueue(req.params.name);
|
||||||
|
if (!queue) return res.status(404).json({ error: "queue_not_found" });
|
||||||
|
try {
|
||||||
|
await queue.obliterate({ force: true });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/queues/pause-all", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await Promise.all([downloadQueue.pause(), removeQueue.pause(), cacheQueue.pause()]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, _req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function queueStats(queue: Queue) {
|
||||||
|
const [counts, workers, paused, completedMetrics, failedMetrics] =
|
||||||
|
await Promise.all([
|
||||||
|
queue.getJobCounts(...QUEUE_STATES),
|
||||||
|
queue.getWorkers().catch(() => []),
|
||||||
|
queue.isPaused().catch(() => false),
|
||||||
|
queue.getMetrics("completed", 0, 119).catch(() => ({ data: [], count: 0 })),
|
||||||
|
queue.getMetrics("failed", 0, 119).catch(() => ({ data: [], count: 0 })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workerCount = workers.length;
|
||||||
|
const concurrency = workerCount > 0 ? (workers as any)[0]?.opts?.concurrency ?? null : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
counts,
|
||||||
|
paused,
|
||||||
|
workers: workerCount,
|
||||||
|
concurrency,
|
||||||
|
throughput: completedMetrics.data || [],
|
||||||
|
completed24h: completedMetrics.count || 0,
|
||||||
|
failed24h: failedMetrics.count || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
router.get("/queues", async (req, res) => {
|
router.get("/queues", async (req, res) => {
|
||||||
const search = req.query.search ? String(req.query.search).toLowerCase() : "";
|
const search = req.query.search ? String(req.query.search).toLowerCase() : "";
|
||||||
|
const queueName = req.query.queue ? String(req.query.queue) : "";
|
||||||
const stateFilter: JobType | null = req.query.state ? String(req.query.state) as JobType : null;
|
const stateFilter: JobType | null = req.query.state ? String(req.query.state) as JobType : null;
|
||||||
const states: JobType[] = stateFilter && QUEUE_STATES.includes(stateFilter)
|
const states: JobType[] = stateFilter && QUEUE_STATES.includes(stateFilter)
|
||||||
? [stateFilter]
|
? [stateFilter]
|
||||||
: QUEUE_STATES;
|
: QUEUE_STATES;
|
||||||
|
|
||||||
const [download, remove, cache, dCounts, rCounts, cCounts] = await Promise.all([
|
const allQueues: { key: string; label: string; queue: Queue }[] = [
|
||||||
downloadQueue.getJobs(states),
|
{ key: "download", label: "Download", queue: downloadQueue },
|
||||||
removeQueue.getJobs(states),
|
{ key: "remove", label: "Remove", queue: removeQueue },
|
||||||
cacheQueue.getJobs(states),
|
{ key: "cache", label: "Cache cleanup", queue: cacheQueue },
|
||||||
downloadQueue.getJobCounts(...QUEUE_STATES),
|
];
|
||||||
removeQueue.getJobCounts(...QUEUE_STATES),
|
|
||||||
cacheQueue.getJobCounts(...QUEUE_STATES),
|
const statsResults = await Promise.all(
|
||||||
]);
|
allQueues.map(async (q) => ({
|
||||||
|
key: q.key,
|
||||||
|
label: q.label,
|
||||||
|
...(await queueStats(q.queue)),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const target = queueName
|
||||||
|
? allQueues.find((q) => q.key === queueName)
|
||||||
|
: allQueues[0];
|
||||||
|
const targetQueue = target ? target.queue : downloadQueue;
|
||||||
|
|
||||||
|
const jobs = await targetQueue.getJobs(states);
|
||||||
|
|
||||||
const matches = (job: { id?: string | undefined; name?: string }) => {
|
const matches = (job: { id?: string | undefined; name?: string }) => {
|
||||||
if (!search) return true;
|
if (!search) return true;
|
||||||
@@ -224,14 +305,9 @@ router.get("/queues", async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
downloadQueue: download.filter(matches),
|
queues: statsResults,
|
||||||
removeQueue: remove.filter(matches),
|
selectedQueue: target?.key || "download",
|
||||||
cacheQueue: cache.filter(matches),
|
jobs: jobs.filter(matches),
|
||||||
counts: {
|
|
||||||
download: dCounts,
|
|
||||||
remove: rCounts,
|
|
||||||
cache: cCounts,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -436,6 +512,187 @@ router.delete("/errors", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// System overview endpoint: process metrics, queue health, DB counts, daily history
|
||||||
|
router.get("/overview", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const mem = process.memoryUsage();
|
||||||
|
const totalMem = os.totalmem();
|
||||||
|
const freeMem = os.freemem();
|
||||||
|
const cpus = os.cpus();
|
||||||
|
const cpuCount = cpus.length;
|
||||||
|
|
||||||
|
// Average CPU load (1-min) as percentage
|
||||||
|
const loadAvg1m = os.loadavg()[0];
|
||||||
|
const cpuPercent = Math.round((loadAvg1m / cpuCount) * 100);
|
||||||
|
|
||||||
|
// Disk usage via df (root partition)
|
||||||
|
let diskTotal = 0, diskUsed = 0, diskFree = 0, diskPercent = 0, diskMount = "/";
|
||||||
|
try {
|
||||||
|
const dfOut = execSync("df -k / 2>/dev/null", { timeout: 3000 }).toString();
|
||||||
|
const lines = dfOut.trim().split("\n");
|
||||||
|
if (lines.length >= 2) {
|
||||||
|
const cols = lines[1].split(/\s+/);
|
||||||
|
diskTotal = parseInt(cols[1], 10) * 1024 || 0;
|
||||||
|
diskUsed = parseInt(cols[2], 10) * 1024 || 0;
|
||||||
|
diskFree = parseInt(cols[3], 10) * 1024 || 0;
|
||||||
|
diskPercent = diskTotal ? Math.round((diskUsed / diskTotal) * 100) : 0;
|
||||||
|
diskMount = cols[cols.length - 1] || "/";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// df not available or timed out
|
||||||
|
}
|
||||||
|
|
||||||
|
const now24h = new Date(Date.now() - 24 * 3600 * 1000);
|
||||||
|
|
||||||
|
const [
|
||||||
|
statusBreakdown,
|
||||||
|
totalSize,
|
||||||
|
recentErrors,
|
||||||
|
totalUsers,
|
||||||
|
totalConferences,
|
||||||
|
totalRepos,
|
||||||
|
activeRepos24h,
|
||||||
|
newRepos24h,
|
||||||
|
newUsers24h,
|
||||||
|
dCounts,
|
||||||
|
rCounts,
|
||||||
|
cCounts,
|
||||||
|
] = await Promise.all([
|
||||||
|
AnonymizedRepositoryModel.aggregate([
|
||||||
|
{ $group: { _id: "$status", count: { $sum: 1 }, storage: { $sum: "$size.storage" } } },
|
||||||
|
]),
|
||||||
|
AnonymizedRepositoryModel.aggregate([
|
||||||
|
{ $group: { _id: null, total: { $sum: "$size.storage" } } },
|
||||||
|
]),
|
||||||
|
AnonymizedRepositoryModel.countDocuments({
|
||||||
|
status: "error",
|
||||||
|
statusDate: { $gte: now24h },
|
||||||
|
}),
|
||||||
|
UserModel.estimatedDocumentCount(),
|
||||||
|
ConferenceModel.estimatedDocumentCount(),
|
||||||
|
AnonymizedRepositoryModel.estimatedDocumentCount(),
|
||||||
|
AnonymizedRepositoryModel.countDocuments({
|
||||||
|
lastView: { $gte: now24h },
|
||||||
|
}),
|
||||||
|
AnonymizedRepositoryModel.countDocuments({
|
||||||
|
anonymizeDate: { $gte: now24h },
|
||||||
|
}),
|
||||||
|
UserModel.countDocuments({
|
||||||
|
dateOfEntry: { $gte: now24h },
|
||||||
|
}),
|
||||||
|
downloadQueue.getJobCounts(...QUEUE_STATES),
|
||||||
|
removeQueue.getJobCounts(...QUEUE_STATES),
|
||||||
|
cacheQueue.getJobCounts(...QUEUE_STATES),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Error stats (from Redis hourly counters)
|
||||||
|
let errorStats = { last24h: 0, severity: { error: 0, warn: 0, info: 0 } };
|
||||||
|
try {
|
||||||
|
const client = await getErrorLogClient();
|
||||||
|
if (client) {
|
||||||
|
const nowDate = new Date();
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (let i = 23; i >= 0; i--) {
|
||||||
|
const d = new Date(nowDate.getTime() - i * 3600 * 1000);
|
||||||
|
const anchor = new Date(
|
||||||
|
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours())
|
||||||
|
);
|
||||||
|
const y = anchor.getUTCFullYear();
|
||||||
|
const m = String(anchor.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(anchor.getUTCDate()).padStart(2, "0");
|
||||||
|
const h = String(anchor.getUTCHours()).padStart(2, "0");
|
||||||
|
keys.push(`${ERROR_LOG_HOURLY_PREFIX}${y}${m}${day}${h}`);
|
||||||
|
}
|
||||||
|
const pipe = client.multi();
|
||||||
|
for (const k of keys) pipe.hGetAll(k);
|
||||||
|
const results = (await pipe.exec()) as unknown as Record<string, string>[];
|
||||||
|
let total = 0;
|
||||||
|
const sev = { error: 0, warn: 0, info: 0 };
|
||||||
|
for (const h of results) {
|
||||||
|
const flat = h || {};
|
||||||
|
total += parseInt(flat.total || "0", 10) || 0;
|
||||||
|
sev.error += parseInt(flat["bucket:error"] || "0", 10) || 0;
|
||||||
|
sev.warn += parseInt(flat["bucket:warn"] || "0", 10) || 0;
|
||||||
|
sev.info += parseInt(flat["bucket:info"] || "0", 10) || 0;
|
||||||
|
}
|
||||||
|
errorStats = { last24h: total, severity: sev };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Redis unavailable — keep defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily history (last 30 days) from DailyStatsModel
|
||||||
|
let history: Array<Record<string, unknown>> = [];
|
||||||
|
try {
|
||||||
|
const { default: DailyStatsModel } = await import(
|
||||||
|
"../../core/model/dailyStats/dailyStats.model"
|
||||||
|
);
|
||||||
|
const since = new Date();
|
||||||
|
since.setUTCDate(since.getUTCDate() - 29);
|
||||||
|
since.setUTCHours(0, 0, 0, 0);
|
||||||
|
const docs = await DailyStatsModel.find({ date: { $gte: since } })
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean();
|
||||||
|
history = docs.map((d) => ({
|
||||||
|
date: d.date,
|
||||||
|
nbRepositories: d.nbRepositories,
|
||||||
|
nbUsers: d.nbUsers,
|
||||||
|
nbPageViews: d.nbPageViews,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// DailyStats collection might not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
system: {
|
||||||
|
platform: os.platform(),
|
||||||
|
arch: os.arch(),
|
||||||
|
nodeVersion: process.version,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
cpuCount,
|
||||||
|
cpuPercent,
|
||||||
|
loadAvg: os.loadavg(),
|
||||||
|
memTotal: totalMem,
|
||||||
|
memFree: freeMem,
|
||||||
|
memUsed: totalMem - freeMem,
|
||||||
|
memPercent: Math.round(((totalMem - freeMem) / totalMem) * 100),
|
||||||
|
processRss: mem.rss,
|
||||||
|
processHeapUsed: mem.heapUsed,
|
||||||
|
processHeapTotal: mem.heapTotal,
|
||||||
|
diskTotal,
|
||||||
|
diskUsed,
|
||||||
|
diskFree,
|
||||||
|
diskPercent,
|
||||||
|
diskMount,
|
||||||
|
},
|
||||||
|
repos: {
|
||||||
|
total: totalRepos,
|
||||||
|
statusBreakdown,
|
||||||
|
totalStorage: totalSize[0]?.total || 0,
|
||||||
|
recentErrors24h: recentErrors,
|
||||||
|
activeRepos24h,
|
||||||
|
newRepos24h,
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
total: totalUsers,
|
||||||
|
newUsers24h,
|
||||||
|
},
|
||||||
|
conferences: {
|
||||||
|
total: totalConferences,
|
||||||
|
},
|
||||||
|
queues: {
|
||||||
|
download: dCounts,
|
||||||
|
remove: rCounts,
|
||||||
|
cache: cCounts,
|
||||||
|
},
|
||||||
|
errors: errorStats,
|
||||||
|
history,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Global stats endpoint: counts by status, total disk, recent failures
|
// Global stats endpoint: counts by status, total disk, recent failures
|
||||||
router.get("/stats", async (req, res) => {
|
router.get("/stats", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -856,23 +1113,81 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/users/:username/promote",
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const result = await UserModel.updateOne(
|
||||||
|
{ username: req.params.username },
|
||||||
|
{ $set: { isAdmin: true } }
|
||||||
|
);
|
||||||
|
if (result.matchedCount === 0) {
|
||||||
|
throw new AnonymousError("user_not_found", { httpStatus: 404 });
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/users/:username/demote",
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const result = await UserModel.updateOne(
|
||||||
|
{ username: req.params.username },
|
||||||
|
{ $set: { isAdmin: false } }
|
||||||
|
);
|
||||||
|
if (result.matchedCount === 0) {
|
||||||
|
throw new AnonymousError("user_not_found", { httpStatus: 404 });
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.get("/conferences", async (req, res) => {
|
router.get("/conferences", async (req, res) => {
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 1000);
|
const limit = Math.min(parseInt(req.query.limit as string) || 10, 1000);
|
||||||
const skipIndex = (page - 1) * limit;
|
const skipIndex = (page - 1) * limit;
|
||||||
|
|
||||||
|
const ready = req.query.ready == "true";
|
||||||
|
const error = req.query.error == "true";
|
||||||
|
const preparing = req.query.preparing == "true";
|
||||||
|
const expired = req.query.expired == "true";
|
||||||
|
const removed = req.query.removed == "true";
|
||||||
|
|
||||||
const sort = parseSort(req);
|
const sort = parseSort(req);
|
||||||
const filter: Record<string, unknown> = {};
|
const query: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
if (req.query.search) {
|
if (req.query.search) {
|
||||||
const escaped = escapeRegex(req.query.search as string);
|
const escaped = escapeRegex(req.query.search as string);
|
||||||
filter.$or = [
|
const re = { $regex: escaped, $options: "i" };
|
||||||
{ name: { $regex: escaped, $options: "i" } },
|
query.push({
|
||||||
{ conferenceID: { $regex: escaped, $options: "i" } },
|
$or: [
|
||||||
];
|
{ name: re },
|
||||||
|
{ conferenceID: re },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (req.query.status) filter.status = req.query.status;
|
|
||||||
const dateFilter = parseDateRange(req, "startDate");
|
const dateFilter = parseDateRange(req, "startDate");
|
||||||
if (dateFilter) Object.assign(filter, dateFilter);
|
if (dateFilter) query.push(dateFilter);
|
||||||
|
|
||||||
|
const status: { status: string }[] = [];
|
||||||
|
if (ready) status.push({ status: "ready" });
|
||||||
|
if (error) status.push({ status: "error" });
|
||||||
|
if (preparing) status.push({ status: "preparing" });
|
||||||
|
if (expired) status.push({ status: "expired" });
|
||||||
|
if (removed) status.push({ status: "removed" });
|
||||||
|
if (status.length > 0) {
|
||||||
|
query.push({ $or: status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = query.length ? { $and: query } : {};
|
||||||
|
|
||||||
if (req.query.format === "csv") {
|
if (req.query.format === "csv") {
|
||||||
const all = await ConferenceModel.find(filter).sort(sort).limit(50000).lean();
|
const all = await ConferenceModel.find(filter).sort(sort).limit(50000).lean();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { downloadQueue } from "../../queue";
|
|||||||
import { RepositoryStatus } from "../../core/types";
|
import { RepositoryStatus } from "../../core/types";
|
||||||
import User from "../../core/User";
|
import User from "../../core/User";
|
||||||
import { streamAnonymizedZip } from "../../core/zipStream";
|
import { streamAnonymizedZip } from "../../core/zipStream";
|
||||||
|
import FileModel from "../../core/model/files/files.model";
|
||||||
import { createLogger, serializeError } from "../../core/logger";
|
import { createLogger, serializeError } from "../../core/logger";
|
||||||
import gh = require("parse-github-url");
|
import gh = require("parse-github-url");
|
||||||
|
|
||||||
@@ -185,6 +186,114 @@ router.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:repoId/files/counts",
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
res.header("Cache-Control", "no-cache");
|
||||||
|
const repo = await getRepo(req, res);
|
||||||
|
if (!repo) return;
|
||||||
|
try {
|
||||||
|
const repoId = repo.repoId;
|
||||||
|
const results = await FileModel.aggregate([
|
||||||
|
{ $match: { repoId, size: { $ne: null } } },
|
||||||
|
{ $project: { path: 1 } },
|
||||||
|
{ $group: { _id: "$path", count: { $sum: 1 } } },
|
||||||
|
]).exec();
|
||||||
|
|
||||||
|
const directCounts: Record<string, number> = {};
|
||||||
|
for (const r of results) {
|
||||||
|
directCounts[r._id ?? ""] = r.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderCounts: Record<string, number> = {};
|
||||||
|
for (const [folder, count] of Object.entries(directCounts)) {
|
||||||
|
let p = folder;
|
||||||
|
folderCounts[p] = (folderCounts[p] || 0) + count;
|
||||||
|
while (p) {
|
||||||
|
const idx = p.lastIndexOf("/");
|
||||||
|
p = idx >= 0 ? p.substring(0, idx) : "";
|
||||||
|
folderCounts[p] = (folderCounts[p] || 0) + count;
|
||||||
|
if (!p) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const terms = repo.options?.terms || [];
|
||||||
|
if (terms.length > 0) {
|
||||||
|
const { anonymizePathCompiled, compileTerms } = await import(
|
||||||
|
"../../core/anonymize-utils"
|
||||||
|
);
|
||||||
|
const compiled = compileTerms(terms);
|
||||||
|
const anonymized: Record<string, number> = {};
|
||||||
|
for (const [folder, count] of Object.entries(folderCounts)) {
|
||||||
|
const anonFolder = anonymizePathCompiled(folder, compiled);
|
||||||
|
anonymized[anonFolder] = (anonymized[anonFolder] || 0) + count;
|
||||||
|
}
|
||||||
|
return res.json(anonymized);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(folderCounts);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:repoId/files/search",
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
res.header("Cache-Control", "no-cache");
|
||||||
|
const repo = await getRepo(req, res);
|
||||||
|
if (!repo) return;
|
||||||
|
try {
|
||||||
|
const query = ((req.query.q as string) || "").trim();
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
const allFiles = await repo.anonymizedFiles({
|
||||||
|
includeSha: false,
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
|
||||||
|
// Collect folder paths whose name segment matches the query
|
||||||
|
const matchingFolders = new Set<string>();
|
||||||
|
for (const f of allFiles) {
|
||||||
|
const segments = (f.path || "").split("/").filter(Boolean);
|
||||||
|
let accumulated = "";
|
||||||
|
for (const seg of segments) {
|
||||||
|
accumulated = accumulated ? `${accumulated}/${seg}` : seg;
|
||||||
|
if (seg.toLowerCase().includes(q)) {
|
||||||
|
matchingFolders.add(accumulated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = allFiles.filter((f) => {
|
||||||
|
// File name matches
|
||||||
|
if (f.name?.toLowerCase().includes(q)) return true;
|
||||||
|
// File is inside a matching folder
|
||||||
|
const fullPath = f.path ? `${f.path}/${f.name}` : f.name;
|
||||||
|
let found = false;
|
||||||
|
matchingFolders.forEach((folder) => {
|
||||||
|
if (fullPath?.startsWith(folder + "/") || fullPath === folder) found = true;
|
||||||
|
})
|
||||||
|
if (found) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
matches.slice(0, 500).map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
path: f.path,
|
||||||
|
size: f.size,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, res, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/:repoId/options",
|
"/:repoId/options",
|
||||||
async (req: express.Request, res: express.Response) => {
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user