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.
For whoever steers the team, it points out what is stalled, overdue or waiting for review, follows milestone progress, and reports on named teams over weeks, months and quarters. All of this works on GitLab Free (see steering signals).
Author: Marcel Petrick mail@marcelpetrick.it
License: GPLv3 or later. See LICENSE.
Note: project is generated with AI.
Needs attention. One list across everyone on the dashboard: stalled work (no update for a week means something is wrong), overdue and due-soon items, MRs waiting for review, MRs that can't be merged and MRs without a reviewer. Click a tile to filter.
Milestones. Progress, remaining estimate, unestimated and overdue issues and a burn-up line for the milestones your people work in. GitLab Free has no burndown API, so Team Pulse computes it from each milestone's issues.
| Team report: totals vs the previous period, share of effort, knowledge spread | Team editor: members and project scope |
|---|---|
![]() |
![]() |
| About: version, author, license and dependencies | Dark mode |
|---|---|
![]() |
![]() |
| People view | Outage handling |
|---|---|
![]() |
![]() |
| 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 (screenshot above).
- 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).
- Needs attention: one list across all monitored people of stalled items (no update for
a week means something is wrong), overdue and due-soon work, MRs waiting for review, MRs that
can't be merged, and MRs without a reviewer in the team. Open items stay visible however old
they are; work items show
spent / estimatefrom GitLab time tracking. - Milestones: progress, remaining estimate, unestimated and overdue issues and a burn-up line, computed from each milestone's issues (GitLab Free has no burndown API).
- Teams: named, shared groups of people with an optional project scope. One click shows a team on the dashboard. The team report compares hours, merged MRs, closed issues, reviews, MR cycle time and estimate accuracy with the previous period, shows the share of effort per project and which projects depend on a single person, and copies last week as Markdown. Totals are team-level; a per-person table is optional (details).
- 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.
- Self-explaining UI: every button, filter, number, badge and column header has a tooltip that says what it is, how it is counted and what clicking does (enforced by a browser test).
- About dialog: version, author, license, source link and the runtime dependencies with their versions and licenses, read from the installed package.
- 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 |
TEAMPULSE_STALLED_DAYS |
7 |
Open items without an update for this long are "stalled" |
TEAMPULSE_DUE_SOON_DAYS |
7 |
Due dates within this many days are "due soon" |
TEAMPULSE_MILESTONES_MAX |
12 |
Milestones whose progress is computed (0 turns it off) |
TEAMPULSE_MAX_TRACKED_USERS |
30 |
Selected users plus team members whose history is kept |
TEAMPULSE_TEAMS_READONLY |
false |
Lock creating, editing and deleting teams |
TEAMPULSE_TEAM_PERSON_BREAKDOWN |
false |
Show the per-person table in team reports |
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/users/teams versions) · reads /api/dashboard, /api/users, /api/teams · 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 · history and milestones hourly · 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, milestones, teams, daily history aggregates, outcomes, 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), attention list and HTTP API |
teams.py |
Team payloads, team reports from the history aggregates, weekly digest |
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, /api/projects,
PATCH /api/users/{id}/selection, POST /api/refresh (202 or 429 with Retry-After),
GET|POST /api/teams, PUT|DELETE /api/teams/{id}, POST /api/teams/{id}/apply,
GET /api/teams/{id}/report?period=week|month|quarter&offset=N and
GET /api/teams/{id}/digest and GET /api/about. 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.AGENTS.md: how to work in this repository (commands, quality gate, architecture and frontend rules, release flow), written for AI coding agents and humans alike.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.docs/PERFORMANCE.md: profiling of startup, sync, API reads and the browser on real data, and what was changed or decided because of it.docs/features/steering-signals.md: needs attention, milestones, teams and team reports: rules, counting, GitLab Free workarounds and load.ideas20261015.mdandplan.md: the reviewed feature ideas and the implementation plan behind v0.4.0.CHANGELOG.md: release history.SECURITY.md: how to report vulnerabilities and the deployment assumptions.










