A people-centric dashboard for a self-managed GitLab instance. Pick a handful of GitLab accounts once, and everyone who opens the page sees what those people have recently worked on: assigned issues and merge requests in every state, their last twelve actions, seven days of activity and seven days of actual GitLab timelogs. It also shows exactly how fresh all of it is.
Author: Marcel Petrick mail@marcelpetrick.it
License: GPLv3 or later. See LICENSE.
Note: project is generated with AI.
| Dark mode | People view |
|---|---|
![]() |
![]() |
| Contribution calendar (2D) | Contribution skyline (3D, rotate and zoom) |
![]() |
![]() |
When GitLab is unreachable, the dashboard keeps the last known good data, marks it with an icon and a text label (color is never the only signal), and explains the problem in the diagnostics drawer:
- Complete user directory: every account the token can see, including bots, service accounts, and blocked or deactivated users. It refreshes hourly, supports a live filter while typing and six sort orders, and the selection survives filtering.
- Global, persistent selection: stored in SQLite and shared by all viewers. A newly selected user is synchronized right away.
- Per-person card:
- Work: assigned issues, merge requests, MRs to review and epics (where the instance supports them, see GraphQL) in every state, sorted newest first. You can re-sort locally by update time, title, project, state, due date or type.
- Activity: the five latest actions at a glance, with all twelve one click away.
- Charts: a stacked seven-day activity chart (pushes, comments, issues, merge requests, other) and daily logged time with per-project totals.
- Contributions: the last 12 months as a GitLab-style calendar, or as a 3D "skyline" you can rotate and zoom. Counts use GitLab's contribution rule, computed from events (see the feature notes).
- Facts only: time comes from GitLab timelogs (one GraphQL query per user), never estimated. Nothing is scored, and there is no hard-coded 40-hour judgment. A week with no logged time is simply highlighted.
- Cached first: the browser reads only the local SQLite cache and polls a compact
/api/statusfor adata_versionchange. GitLab is crawled in the background: selected users every ten minutes, the directory hourly, and immediately on Refresh now. Duplicate refreshes are coalesced and refresh storms are throttled. - Last known good wins: a failed refresh never replaces good data. Retention cleanup keeps SQLite compact but never deletes the only good snapshot, even after days of outage.
- Explicit freshness: exact timestamps plus "10 seconds ago" for everything. It distinguishes fresh, refreshing, stale, error and never-synced states, per user and per dataset (work, activity, timelogs fail independently).
- Low GitLab load: detailed crawling only for selected users, project metadata only on demand, incremental activity sync, bounded concurrency, and retries with backoff and jitter. Authentication errors fail fast.
- Polished, dependency-free UI: plain HTML, CSS and ES modules served by FastAPI, native SVG charts, no CDN, and no Node.js build. It has light and dark themes (kept for the browser session), keyboard support, and all data text stays selectable and copyable.
uv sync
uv run gitlab-team-pulse demo # http://127.0.0.1:8000, backed by a built-in fake GitLabThe demo starts a deterministic fake GitLab API (32 accounts, 6 projects, work, events and timelogs), pre-selects four people and serves the dashboard. With Docker:
docker run --rm -p 8000:8000 ghcr.io/marcelpetrick/gitlabteampulse:latest demo --host 0.0.0.0TL;DR
- In GitLab: avatar → Edit profile → Access tokens → Add new token
(
https://<your-gitlab>/-/user_settings/personal_access_tokens). Tick only theread_apiscope, then copy theglpat-…token. - In a terminal:
git clone https://github.com/marcelpetrick/GitLabTeamPulse.git && cd GitLabTeamPulse
uv sync
export TEAMPULSE_GITLAB_URL=https://gitlab.example.com # base URL, no trailing path
read -rs "TEAMPULSE_GITLAB_TOKEN?GitLab token: " && export TEAMPULSE_GITLAB_TOKEN # zsh; bash: read -rsp "GitLab token: " TEAMPULSE_GITLAB_TOKEN && export TEAMPULSE_GITLAB_TOKEN
export TEAMPULSE_TIMEZONE=Europe/Berlin # optional: calendar days in local time
uv run gitlab-team-pulse doctor # checks URL, token, admin rights and the timelogs query
uv run gitlab-team-pulse serve # open http://127.0.0.1:8000- In the browser: People → tick the colleagues to follow → Dashboard. The first sync starts right away; after that it refreshes every 10 minutes or on Refresh now. The first sync with the contribution calendar backfills a year of events per person (about 70 s for 6 people on a real instance); later refreshes are incremental.
Details
-
Token rights: a
read_apitoken is enough; no write scope is ever needed. An administrator (or Auditor) account is recommended. Without it, GitLab hides blocked, deactivated and internal accounts, and the dashboard says so in Diagnostics instead of claiming completeness. The top-level GraphQLtimelogsquery may also need admin rights (seedocs/GRAPHQL.md). -
Keep the token in a file instead of the shell:
mkdir -p ~/.config/gitlab-team-pulse ( umask 077; read -rs "t?GitLab token: "; printf '%s' "$t" > ~/.config/gitlab-team-pulse/token ) export TEAMPULSE_GITLAB_TOKEN_FILE=~/.config/gitlab-team-pulse/token
-
Troubleshooting
doctor:- Redirected (301/302): the URL scheme, host or path is off.
- Certificate error: set
TEAMPULSE_GITLAB_CA_BUNDLE=/path/to/company-ca.pem. - Timelogs unavailable: the account lacks the rights; everything else still works.
-
State: stored in
~/.local/share/gitlab-team-pulse/teampulse.db, so the selection and the cache survive restarts. The demo uses a separatedemo.db. -
Standalone install:
make build && pipx install dist/gitlab_team_pulse-*.whl(oruv tool install .).
mkdir -p secrets && printf '%s' 'glpat-...' > secrets/gitlab_token && chmod 600 secrets/gitlab_token
TEAMPULSE_GITLAB_URL=https://gitlab.example.com docker compose up -dcompose.yaml mounts the token as a Docker secret (/run/secrets/gitlab_token,
read automatically) and keeps SQLite on the teampulse-data volume. The container:
- runs as the non-root user
teampulse(uid 10001) with/dataat mode 0700; - applies migrations as an explicit startup step, then runs Gunicorn with exactly one Uvicorn worker, so the in-process scheduler has a single owner;
- logs human-readable lines to stdout and exposes a
HEALTHCHECKon/api/health.
Images are published to ghcr.io/marcelpetrick/gitlabteampulse for linux/amd64 and
linux/arm64, tagged latest, <version>, vX.Y tags and sha-<commit>.
All settings are environment variables. None of them require a code change.
make run / gitlab-team-pulse serve also reads an ignored .env file from the working
directory; for another file use uv run --env-file path/to/file.env gitlab-team-pulse serve.
| Variable | Default | Purpose |
|---|---|---|
TEAMPULSE_GITLAB_URL |
— | Base URL of the self-managed GitLab |
TEAMPULSE_GITLAB_TOKEN |
— | API token (fallback when no secret file exists) |
TEAMPULSE_GITLAB_TOKEN_FILE |
/run/secrets/gitlab_token |
Secret file, preferred over the variable |
TEAMPULSE_GITLAB_VERIFY_TLS |
true |
TLS verification |
TEAMPULSE_GITLAB_CA_BUNDLE |
— | Custom CA bundle for on-premise certificates |
TEAMPULSE_GITLAB_CONCURRENCY |
4 |
Maximum parallel GitLab requests |
TEAMPULSE_GITLAB_TIMEOUT_SECONDS / _MAX_RETRIES |
20 / 3 |
Request timeout and bounded retries |
TEAMPULSE_DATABASE_PATH |
~/.local/share/gitlab-team-pulse/teampulse.db |
SQLite file (container: /data/teampulse.db) |
TEAMPULSE_HOST / TEAMPULSE_PORT |
127.0.0.1 / 8000 |
Bind address (container: 0.0.0.0) |
TEAMPULSE_LOG_LEVEL |
INFO |
DEBUG … CRITICAL |
TEAMPULSE_SELECTED_REFRESH_INTERVAL_SECONDS |
600 |
Selected-user refresh |
TEAMPULSE_USER_REFRESH_INTERVAL_SECONDS |
3600 |
Directory refresh |
TEAMPULSE_UI_POLL_INTERVAL_SECONDS |
20 |
Browser polling of the local backend |
TEAMPULSE_STALE_GRACE_SECONDS |
120 |
Grace before data is shown as stale |
TEAMPULSE_MANUAL_REFRESH_MIN_INTERVAL_SECONDS |
10 |
Refresh-storm protection |
TEAMPULSE_RETENTION_HOURS / TEAMPULSE_ERROR_RETENTION_DAYS |
24 / 7 |
Rolling cache and resolved-error retention |
TEAMPULSE_WORK_WINDOW_DAYS |
30 |
"Recently relevant" work window |
TEAMPULSE_ACTIVITY_DAYS |
7 |
Activity and timelog window |
TEAMPULSE_CONTRIBUTIONS_REFRESH_MINUTES |
60 |
How often the 12-month contribution calendar is refreshed |
TEAMPULSE_TIMEZONE |
UTC |
Time zone for calendar-day buckets |
The token is never written to SQLite, logs (a redaction filter guards them), API responses or exception text shown in the UI. It is sent only to the configured GitLab host, and pagination links pointing elsewhere are refused.
The full C4 architecture (system context, containers, components, the refresh sequence, the
freshness state model, the data model and the deployment view, all as Mermaid diagrams) is in
docs/ARCHITECTURE.md. In short:
Browser (HTML/CSS/ES modules, SVG charts)
│ polls /api/status (data_version) · reads /api/dashboard, /api/users · PATCH selection · POST refresh
FastAPI app (single worker) ── security headers (CSP, no CORS), cached reads only
│
├── Scheduler (asyncio): directory hourly · selected users every 10 min · cleanup hourly
│ └── manual refresh coalescing and throttling, immediate sync for newly selected users
├── SyncService: per-user, per-dataset transactions; last-known-good; diagnostics
│ └── GitLabClient (httpx): REST + GraphQL (timelogs, epics), pagination, retries, bounded concurrency
└── SQLite (SQLAlchemy + Alembic, WAL, foreign keys) ── users, work items, events, timelogs,
projects, sync state, runs, errors
| Module | Responsibility |
|---|---|
gitlab/ |
The only code that speaks HTTP to GitLab (REST and GraphQL) or reads raw payloads; typed errors |
sync.py, scheduler.py |
Synchronization, freshness bookkeeping, scheduling |
store.py, models.py, migrations/ |
Persistence and schema history |
retention.py |
Rolling cleanup that never removes the only good snapshot |
dashboard.py, app.py |
Batched read model (no N+1 queries) and HTTP API |
fake_gitlab.py |
Deterministic fake GitLab for demo, integration and browser tests |
HTTP API: GET /api/health, /api/status, /api/users, /api/dashboard,
/api/users/{id}/work|activity|time-summary, /api/errors, PATCH /api/users/{id}/selection,
POST /api/refresh (202 or 429 with Retry-After). Interactive docs are at /api/docs.
Why and how GitLab's GraphQL API is used (timelogs and epics) and what it saves compared
with REST is explained in docs/GRAPHQL.md.
make install # uv sync --locked
make test # unit + integration tests, coverage gate 95%
make e2e # Playwright/Chromium browser tests against real HTTP servers
make lint typecheck format
make run # serve against your GitLab (reads .env from the working directory)
make demo # run against the fake GitLab
./localPipeline.sh # everything CI runs, plus a Docker smoke test and an optional demo launchmake demo and make run both bind port 8000, so stop the demo before make run.
localPipeline.sh runs these stages: uv sync, Ruff lint, Ruff format check, strict mypy, a
migration of a fresh database, tests with coverage, browser E2E, package build, and a wheel
smoke test in a clean venv (serving the demo from the installed wheel). It then runs a Docker
build with a container smoke test, opens the coverage report and launches the demo, and prints
a summary. The GitHub Actions workflow and .gitlab-ci.yml run the same
script. Docker Image publishes to GHCR after an end-to-end container smoke test that covers a
Docker secret, a fake GitLab and a restart with a persistent volume.
Tests never need a live GitLab. They cover normalization, pagination, retries and rate limits, freshness, retention, last-known-good behaviour (including the VISION §37.3 outage scenario), migrations and drift, the API, the CLI and the browser flows from VISION §37.4.
Semantic Versioning: every commit bumps the patch version and major
features bump the minor version (tools/bump_version.py). Commits follow
Conventional Commits and are atomic, with the version
appended, e.g. feat(docker): … (v0.1.0). See CHANGELOG.md.
VISION.md: full product requirements and acceptance criteria.docs/ARCHITECTURE.md: C4 architecture overview with Mermaid diagrams (context, containers, components, dynamic, data and deployment views).docs/GRAPHQL.md: what GraphQL is, why it is used for timelogs and epics, and how many requests it saves compared with REST.CHANGELOG.md: release history.SECURITY.md: how to report vulnerabilities and the deployment assumptions.





