Post

Productivity Dashboard: Technical Architecture

Architecture of the Productivity Dashboard: collectors write JSON, AI generators write Markdown summaries of summaries, and a FastAPI + React app reads, searches and refreshes them.

Productivity Dashboard: Technical Architecture

The Productivity Dashboard is a pipeline of plain files. Collectors write each person’s daily GitHub and Jira activity as JSON; AI generators read it and write Markdown summaries; higher-level generators summarize the summaries; and a FastAPI + React app reads all of it from one Azure File Share. No database, no queue, no service calling another. The AI is the only clever part, and it sits behind a single client class that can point at Azure OpenAI, OpenAI or a model on a laptop.

System context and component architecture

  1. Where the work happens: People's work lives in GitHub and Jira; readers reach the summaries from a browser. Nobody types a status update.
  2. Collect daily activity: The GitHub, Jira and epic-data collectors pull each person's activity for the day.
  3. Raw activity as JSON: Collectors write github.json, jira.json and epic metadata per person and date to the Azure File Share.
  4. Generators read the share: The user, team, epic and POD generators read the collected JSON. They never call each other; the schedule orders them.
  5. Prompt the model: Prompts from each generator's prompts/ folder go to Azure OpenAI through one BaseAIClient class.
  6. Summary comes back: The model returns the summary. The same client can point at OpenAI or a local model in development.
  7. Summaries of summaries: Each generator writes summary.md per user, team, epic, POD and period. The team generator waits for user summaries first.
  8. Someone opens the dashboard: A manager opens the dashboard; FastAPI on port 9000 serves the React app and the read-mostly API.
  9. Read files, cache in Redis: The API reads summaries read-only from the share and memoizes reads and search results in Redis.
  10. Refresh on demand: The Refresh button makes the API create a one-shot Kubernetes Job that regenerates that summary.
  11. Fresh summary on the share: The Job writes a new summary.md; the UI polls the refresh status and shows it.
TierComponentsWrites
Collectorsprod-github-collector, prod-jira-collector, prod-epic-data-collectorgithub.json, jira.json, epic metadata and daily jira-updates.json
Generatorsprod-user-summary-generator, prod-team-summary-generator, prod-epic-summary-generator, prod-pod-summary-generatorsummary.md per user, team, epic, POD and period
UIproductivity-ui (FastAPI, React, Redis)Nothing on the share; creates refresh Jobs

The generators don’t call each other. Their order comes from the schedule, plus one explicit wait: the team generator polls for user summaries before it starts.

Technology and framework selection

NeedChoiceWhy
JobsPython 3.11 / 3.12 slimAPI clients and AI SDKs; readable for the team
AIAzure OpenAI in production via BaseAIClient; OpenAI and LM Studio as alternativesEnterprise tenancy in production; a free local model for development
PromptsPlain text and Jinja2 templates in each generator’s prompts/ folderPrompts versioned and reviewed like code
RetriestenacityGitHub, Jira and AI APIs all rate-limit
BackendFastAPI + Uvicorn, Pydantic 2Typed API, async file reads
FrontendReact 18, Vite 5, Tailwind CSS 3, react-markdown 9 + remark-gfmFast builds; good Markdown rendering
CacheRedis, as its own Deployment in the chartMemoize reads and search results
RefreshKubernetes Python clientLaunch one-shot Jobs from the API

More on running models locally in Running LLMs on a home PC, and on the service itself in Azure OpenAI Service.

API and integration architecture

The API is read-mostly REST, one group per summary level:

1
2
3
4
5
6
7
8
GET  /api/summaries?user=&date=&period=        GET /api/team-summaries?team=&date=&period=
GET  /api/epics, /api/epic-summaries, /api/epic-team-summaries, /api/epic-stories
GET  /api/pod-summary?pod=&date=&period=       GET /api/pod-data
GET  /api/collector-data[/aggregated]          # raw source for a summary
POST /api/search                               # full text across all summaries
POST /api/refresh, GET /api/refresh/status     # on-demand regeneration
GET  /api/config, /api/metadata, /api/health, /api/storage-check, /api/cache/stats
POST /api/cache/clear

Outbound integrations live in the jobs: GitHub REST and GraphQL (per-org GitHub Apps), Jira Cloud REST (email and API token), and the AI provider. The UI’s one outward action is to the Kubernetes API, to create refresh Jobs.

Data architecture and data models

Data is organized the way people are: department, team, person, then period and date.

1
2
3
4
5
/data/{department}/{team}/{user}/daily/{YYYY}/{MM}/{DD}/github.json | jira.json
/data/{department}/{team}/Users/{user}/{daily|weekly|monthly}/…/summary.md
/data/{department}/{team}/{daily|weekly|monthly}/…/summary.md
/data/{department}/{team}/Epics/{EPIC-KEY}/epic-metadata.json, {date}/jira-updates.json, epic-summary.md
/data/{org_prefix}/PODS/{pod_key}/{period}/…/summary.md   (+ per-member summaries)

The key modelling decision is summaries of summaries: weekly summaries are built from dailies, monthly from weeklies, and team summaries from user summaries. That keeps every prompt small, and the roll-ups consistent with what people already read at the lower level. The cost is that an error in a daily summary can carry upward, which is one more reason accuracy is checked at the bottom.

Identity and access architecture

From → toMechanism
Collectors → GitHubPer-org GitHub App installation tokens via GitHubAppTokenManager; PAT fallback
Collectors → JiraBasic auth with an email and API token
Generators → Azure OpenAIAPI key
All pods → Azure FilesWorkload Identity on the productivity ServiceAccount; storage key fallback
UI → Kubernetes APIA namespaced Role: create, get, list, watch, delete Jobs; read CronJobs, pods and logs
SecretsExternal Secrets Operator from Azure Key Vault

The people model is configuration: each person is a tuple of environment variables (department, team, name, GitHub username, Jira email) indexed the same way in collectors and generators.

Hosting and deployment model

Everything runs in the productivity namespace: AKS in production, k3d locally. The UI chart carries the generator schedules in its values, and the collectors and generators have their own charts in the repo. A productivity-shared-infra chart provides the ClusterSecretStore, the ExternalSecrets and the shared PV/PVC. Images go to JFrog; ArgoCD deploys from a GitOps repository. Details in Infrastructure and Environments.

Scalability and performance strategy

  • People per run: the user generator works in parallel (MAX_PARALLEL_USERS, default 4), bounded by the AI deployment’s tokens-per-minute quota more than by CPU.
  • Prompt size: summaries-of-summaries keep weekly, monthly and team prompts short regardless of team size.
  • Reads: Redis memoizes expensive reads; summaries are small Markdown files.
  • Search: full-text search scans summaries under /data. That’s fine today, but the files are kept forever, so search time grows with history. A small index (SQLite FTS, or rebuilding an index nightly after the generators) would keep it fast.
  • UI replicas: one, with an autoscaling block in values.yaml that no template uses yet. The fix is in Operations and Observability.

Resilience and disaster recovery

  • Generators are re-runnable per person and date (TARGET_USER, TARGET_DATE, FORCE_REGENERATE), which is exactly what the UI’s Refresh uses.
  • Collectors can re-collect (days_to_collect, force_recollect) to backfill missed days, within what the GitHub and Jira APIs still return.
  • If the AI provider is down, the provider setting can switch to another backend and the jobs re-run.
  • Code and config rebuild from Git. Summaries can be regenerated from collector data, but only approximately, since a new prompt or model writes different text; for anything used in a review, the original summary is the record.

TODO: Is the file share backed up? Given that summaries feed performance reviews, the originals probably need a defined retention and backup, agreed with HR.

Architectural decision records

ADRDecision
001Files on Azure Files: JSON for collected data, Markdown for summaries; no database
002Summaries of summaries for weekly, monthly and team levels
003One provider-agnostic AI client (Azure OpenAI, OpenAI, LM Studio)
004Prompts as versioned template files next to each generator
005People, teams and PODs as configuration, never code
006On-demand refresh as Kubernetes Jobs launched by the UI
007UI views and lists driven by a ConfigMap through /api/config

ADR-002 is the one with the most interesting trade-off, so it’s the one I’d write out in full:

1
2
3
4
5
6
7
8
9
# ADR-002: Build higher-level summaries from lower-level summaries

Status: Accepted
Context: Weekly, monthly, team and POD summaries could each be generated from raw GitHub and Jira data,
  but raw data grows with time and team size, and so would the prompts and the cost.
Decision: Weekly summaries are generated from daily summaries, monthly from weekly, team from user.
Consequences: Small, predictable prompts; roll-ups consistent with the dailies people already read.
  Errors in a daily summary propagate upward, so accuracy checks focus on dailies, and a refreshed
  daily should trigger regeneration of the weekly that contains it.

Checklist

  • Component map and schedule documented, including the team generator’s wait
  • AI access only through BaseAIClient; provider set by configuration
  • Data layout built only through path_utils
  • UI Role limited to Jobs in its own namespace
  • Search index planned before history makes scanning slow
  • HPA template added for the UI
  • Backup and retention of original summaries agreed
  • ADRs 001-007 written down

This post is licensed under CC BY 4.0 by the author.