{"id":50579816,"url":"https://github.com/home-operations/kromgo","last_synced_at":"2026-06-10T12:00:48.041Z","repository":{"id":212713016,"uuid":"731863942","full_name":"home-operations/kromgo","owner":"home-operations","description":"Expose prometheus metrics to the outside using badges or graphs","archived":false,"fork":false,"pushed_at":"2026-06-09T15:01:53.000Z","size":615,"stargazers_count":148,"open_issues_count":1,"forks_count":4,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-06-09T16:25:51.626Z","etag":null,"topics":["0ver","badges-markdown","graphs-markdown","kubernetes","prometheus","victoria-metrics"],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"agpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/home-operations.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2023-12-15T03:57:59.000Z","updated_at":"2026-06-09T15:18:56.000Z","dependencies_parsed_at":"2024-08-28T21:27:42.833Z","dependency_job_id":"53f59a25-9920-4b4f-8fca-433817814c6a","html_url":"https://github.com/home-operations/kromgo","commit_stats":{"total_commits":21,"total_committers":5,"mean_commits":4.2,"dds":"0.23809523809523814","last_synced_commit":"79574e5ec34039b94d705cefe84eef82cb93038d"},"previous_names":["kashalls/kubernetes-json-shields","home-operations/kromgo"],"tags_count":31,"template":false,"template_full_name":null,"purl":"pkg:github/home-operations/kromgo","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/home-operations%2Fkromgo","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/home-operations%2Fkromgo/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/home-operations%2Fkromgo/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/home-operations%2Fkromgo/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/home-operations","download_url":"https://codeload.github.com/home-operations/kromgo/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/home-operations%2Fkromgo/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34151276,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-10T02:00:07.152Z","response_time":89,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["0ver","badges-markdown","graphs-markdown","kubernetes","prometheus","victoria-metrics"],"created_at":"2026-06-05T01:00:33.199Z","updated_at":"2026-06-10T12:00:48.029Z","avatar_url":"https://github.com/home-operations.png","language":"Go","funding_links":[],"categories":["Go"],"sub_categories":[],"readme":"# Kromgo\n\n[![Tests](https://github.com/home-operations/kromgo/actions/workflows/tests.yaml/badge.svg)](https://github.com/home-operations/kromgo/actions/workflows/tests.yaml)\n[![E2E](https://github.com/home-operations/kromgo/actions/workflows/e2e.yaml/badge.svg)](https://github.com/home-operations/kromgo/actions/workflows/e2e.yaml)\n[![Lint](https://github.com/home-operations/kromgo/actions/workflows/lint.yaml/badge.svg)](https://github.com/home-operations/kromgo/actions/workflows/lint.yaml)\n[![Release](https://img.shields.io/github/v/release/home-operations/kromgo)](https://github.com/home-operations/kromgo/releases)\n[![License](https://img.shields.io/github/license/home-operations/kromgo)](LICENSE)\n[![Discord](https://img.shields.io/discord/673534664354430999?label=discord\u0026logo=discord\u0026logoColor=white\u0026color=blue)](https://discord.gg/home-operations)\n\nSafely expose individual Prometheus metric values to the public web. Define named endpoints backed by PromQL queries and serve them as SVG badges, themed SVG/PNG graphs, or JSON — without exposing your Prometheus instance directly.\n\nBadges render as shields.io-style SVG, so you can embed `/badges/{id}` straight into an `\u003cimg\u003e` tag — no shields.io round-trip required (though it's still supported via `?format=shields`).\n\n## How it works\n\nkromgo sits between the public web and your Prometheus. You define two kinds of endpoint:\n\n- **Badges** (`/badges/{id}`) render an instant value as an SVG badge, shields.io JSON, or kromgo JSON.\n- **Graphs** (`/graphs/{id}`) render a time series as a themed SVG/PNG chart or JSON.\n\nEach maps a URL path to a PromQL query. Only the endpoints you define are reachable — Prometheus itself is never exposed.\n\nThe root path `/` serves a **gallery** that previews every endpoint next to its copy-paste Markdown snippet — handy for grabbing a badge for a README.\n\n## Quick start\n\n```bash\ndocker run -d \\\n  -e PROMETHEUS_URL=http://prometheus:9090 \\\n  -v /path/to/config.yaml:/config/config.yaml \\\n  -p 8080:8080 \\\n  ghcr.io/home-operations/kromgo:latest\n```\n\nThen embed or query a badge:\n\n```html\n\u003cimg src=\"http://localhost:8080/badges/node_cpu_usage\" /\u003e\n```\n\n### Docker Compose\n\n```yaml\nservices:\n    kromgo:\n        image: ghcr.io/home-operations/kromgo:latest\n        environment:\n            PROMETHEUS_URL: http://prometheus:9090\n        volumes:\n            - ./config.yaml:/config/config.yaml:ro\n        ports:\n            - \"8080:8080\"\n```\n\n### Kubernetes (Helm)\n\nkromgo publishes an **OCI** Helm chart to `oci://ghcr.io/home-operations/charts/kromgo`:\n\n```bash\nhelm install kromgo oci://ghcr.io/home-operations/charts/kromgo \\\n  --namespace kromgo --create-namespace \\\n  --set config.prometheus='http://prometheus-operated.monitoring.svc.cluster.local:9090'\n```\n\nThe `config` value is rendered verbatim into a ConfigMap mounted at\n`/config/config.yaml`, so the [config schema](#configuration) below maps directly\nonto it. Notable values (see [`charts/kromgo/values.yaml`](charts/kromgo/values.yaml)):\n\n| Value                                      | Purpose                                                                       |\n| ------------------------------------------ | ----------------------------------------------------------------------------- |\n| `config.prometheus`                        | Prometheus URL kromgo queries                                                 |\n| `config.badges` / `config.graphs`          | the endpoint definitions (same schema as the config file)                     |\n| `existingConfigMap`                        | mount a ConfigMap you manage elsewhere instead of rendering `config`          |\n| `secret.prometheusUrl` / `.existingSecret` | inject `PROMETHEUS_URL` from a Secret when the URL carries credentials        |\n| `ingress.enabled`                          | expose the app via an Ingress                                                 |\n| `httpRoute.enabled`                        | expose the app via a Gateway API `HTTPRoute` (set `parentRefs` + `hostnames`) |\n| `monitoring.serviceMonitor.enabled`        | scrape `/metrics` on the health port (Prometheus Operator)                    |\n\nEvery value is documented in the chart's generated README,\n[`charts/kromgo/README.md`](charts/kromgo/README.md), built from\n[`values.yaml`](charts/kromgo/values.yaml) — which also ships a\n[`values.schema.json`](charts/kromgo/values.schema.json) for editor\nautocompletion and `helm install`-time validation.\n\n## Configuration\n\nkromgo reads its endpoint definitions from `/config/config.yaml` inside the container. Mount your\nconfig file there (or pass `-config /path/to/config.yaml`).\n\n**Minimal example:**\n\n```yaml\nbadges:\n    - id: node_cpu_usage\n      query: \"round(cluster:node_cpu:ratio_rate5m * 100, 0.1)\"\n      valueExpr: string(result) + \"%\"\n```\n\nA JSON Schema for editor validation is published at [config.schema.json](./config.schema.json);\npoint your editor's YAML language server at it for inline completion and validation.\n\n### Environment variables\n\n| Variable               | Required | Default   | Description                                 |\n| ---------------------- | -------- | --------- | ------------------------------------------- |\n| `PROMETHEUS_URL`       | yes      | —         | URL of your Prometheus instance             |\n| `SERVER_HOST`          | no       | `0.0.0.0` | Host to bind the main server                |\n| `SERVER_PORT`          | no       | `8080`    | Port for the main server                    |\n| `HEALTH_HOST`          | no       | `0.0.0.0` | Host to bind the health server              |\n| `HEALTH_PORT`          | no       | `8888`    | Port for the health/metrics server          |\n| `SERVER_LOGGING`       | no       | `false`   | Enable HTTP request access logging          |\n| `SERVER_READ_TIMEOUT`  | no       | —         | HTTP read timeout (e.g. `5s`)               |\n| `SERVER_WRITE_TIMEOUT` | no       | —         | HTTP write timeout (e.g. `10s`)             |\n| `QUERY_TIMEOUT`        | no       | `30s`     | Timeout applied to each Prometheus query    |\n| `LOG_LEVEL`            | no       | `info`    | Log level: `debug`, `info`, `warn`, `error` |\n| `LOG_FORMAT`           | no       | `json`    | Log format: `json` or `text`                |\n\n### Defaults\n\n`defaults` sets the baseline for the per-endpoint fields that support it; each endpoint overrides the\nsame-named field. All keys are optional.\n\n```yaml\ndefaults:\n    badge:\n        font: dejavu-sans # dejavu-sans (default, shields.io-style), dejavu-sans-bold, comic-neue, comic-neue-bold\n        size: 11 # badge font size in points\n        style: flat # flat (default), flat-square, or plastic\n        gallery:\n            hidden: false # list badges in the gallery (default); true hides them\n    graph:\n        maxDuration: 1h # cap on a graph's requested window (\"0\" = unlimited)\n        width: 600 # image width in px\n        height: 200 # image height in px\n        legend: true # show the series legend\n        theme: light # color theme — see Themes below\n        font: dejavu-sans # text font — see Themes below\n        gallery:\n            hidden: false # list graphs in the gallery (default); true hides them\n```\n\nThe gallery page itself is toggled separately at the top level — see [Gallery](#gallery).\n\n### Badges\n\nEach entry under `badges:` defines an instant-value endpoint at `/badges/{id}`.\n\n| Field        | Required | Description                                                                          |\n| ------------ | -------- | ------------------------------------------------------------------------------------ |\n| `id`         | yes      | URL path segment — `cpu` → `GET /badges/cpu`                                         |\n| `query`      | yes      | PromQL expression returning a single scalar or vector value                          |\n| `title`      | no       | Display label on the badge (defaults to `id`)                                        |\n| `type`       | no       | `instant` (default) or `range` — see [Range badges](#range-badges)                   |\n| `range`      | no\\*     | Range-query window when `type: range`                                                |\n| `valueExpr`  | no       | CEL expression for the displayed string — see [Value and color](#value-and-color)    |\n| `colorExpr`  | no       | CEL expression for the color — see [Value and color](#value-and-color)               |\n| `labelColor` | no       | Left-segment (label) color — a name or hex; a fixed value, not a CEL expression      |\n| `style`      | no       | `flat` (default), `flat-square`, or `plastic`                                        |\n| `icon`       | no       | An icon on the SVG badge, e.g. `mdi:server-outline` or `si:kubernetes` — see below   |\n| `gallery`    | no       | Per-badge gallery settings, e.g. `gallery: {hidden: true}` — see [Gallery](#gallery) |\n\n#### Icons\n\n`icon` renders an icon on the left of the SVG badge, written as `\u003cset\u003e:\u003cname\u003e` for one of two sets:\n\n- **`mdi:\u003cname\u003e`** — a [Material Design Icon](https://pictogrammers.com/library/mdi/), e.g. `mdi:server-outline`.\n- **`si:\u003cslug\u003e`** — a [Simple Icons](https://simpleicons.org/) brand logo, e.g. `si:kubernetes`.\n\nIt is **SVG-only** — the `shields` and `json` formats have no icon field and ignore it. With a\n`title`, the icon sits to its left on the label segment, drawn to contrast with the label background\n(white on the default grey, dark on a light `labelColor`). With an icon and **no** `title`, the badge\ncollapses to a single segment — the icon and value share one color and there's no separate label box\n(the `id` fallback is suppressed), mirroring shields.io's empty-label form. To instead keep a\nseparate (colored) icon segment with no text, set `title: \" \"` (a single space).\n\n```yaml\nbadges:\n    - id: nodes\n      query: count(kube_node_info)\n      icon: mdi:server-outline\n      title: Nodes\n    - id: version\n      query: kubernetes_build_info\n      icon: si:kubernetes\n      title: Kubernetes\n```\n\nBoth **entire** sets are embedded in the binary — no network or disk access at runtime — so any\n`mdi:\u003cname\u003e` from [the MDI library](https://pictogrammers.com/library/mdi/) (~7,400 glyphs, e.g.\n`mdi:database-outline`, `mdi:rocket-launch`) or any `si:\u003cslug\u003e` from [Simple Icons](https://simpleicons.org/)\n(~3,400 logos, e.g. `si:docker`, `si:grafana`, `si:prometheus`) works. The sets are stored compressed\n(~0.8 MB MDI, ~1.9 MB Simple Icons) and each is decoded into memory only on first use. An unknown set\nor name fails fast at startup. The icon data is built from the `@mdi/svg` and `simple-icons` npm\npackages **at build time** (not committed) — see [Building from source](#building-from-source).\n\n#### Range badges\n\nBy default a badge's value comes from an **instant** query at \"now\". Set `type: range` to instead run\na **range query** over a window and reduce it to a single value — useful for averages, peaks, or\ncomparing against an earlier period. The window is `end = now - offset`, `start = end - last`.\n\n```yaml\nbadges:\n    - id: cpu_prev_week_avg\n      type: range\n      query: \"cluster:node_cpu:ratio_rate5m * 100\"\n      range:\n          last: \"7d\" # window length (required)\n          offset: \"7d\" # shift the window back; here: 14d ago .. 7d ago (default: ends now)\n          step: \"1h\" # resolution (default: last/100, min 1m)\n          reduce: avg # last (default), first, avg, min, max, sum\n      valueExpr: string(result) + \"%\"\n```\n\n`reduce` collapses each series to one value; non-finite samples (NaN/Inf) are skipped.\n\n### Value and color\n\n`valueExpr` and `colorExpr` are [CEL](https://cel.dev) expressions (the `Expr` suffix marks the\nCEL-evaluated fields; `query` is PromQL and `labelColor` is a static value). CEL is sandboxed (no\nenvironment, file, or network access) and compiled once at startup, so a malformed expression fails\nfast rather than per request. Each expression receives two variables:\n\n| Variable | Type                  | Description                                              |\n| -------- | --------------------- | -------------------------------------------------------- |\n| `result` | `double`              | The sample value (for `type: range`, the reduced value). |\n| `labels` | `map(string, string)` | The sample's labels, e.g. `labels[\"instance\"]`.          |\n\n- **`valueExpr`** must return a string — the message shown on the badge. Defaults to `string(result)`.\n- **`colorExpr`** must return a string — a [shields.io color name](https://shields.io) (`green`,\n  `orange`, `red`, `blue`, `grey`, …) or a hex value like `\"#e05d44\"`. Omit for no color.\n\nText color adapts to the background for legibility — dark text on light colors, white on dark — the\nsame way shields.io does, so a light custom `colorExpr` stays readable. Every badge also carries\n`role=\"img\"`, an `aria-label`, and a `\u003ctitle\u003e` (`\"label: message\"`) for screen readers and tooltips.\n\n```yaml\nbadges:\n    # numeric value with a unit + threshold coloring\n    - id: cpu\n      query: \"round(avg(...) * 100, 0.1)\"\n      valueExpr: string(result) + \"%\"\n      colorExpr: 'result \u003c 35 ? \"green\" : result \u003c 75 ? \"orange\" : \"red\"'\n\n    # value taken from a label, falling back if it's absent\n    - id: version\n      query: 'label_replace(build_info, \"v\", \"$1\", \"version\", \"v(.+)\")'\n      valueExpr: labels[?\"v\"].orValue(\"unknown\")\n\n    # guard a possibly-NaN ratio (e.g. divide-by-zero) before formatting\n    - id: hit_ratio\n      query: cache_hits / (cache_hits + cache_misses)\n      valueExpr: 'math.isNaN(result) ? \"n/a\" : humanizeFloat(math.round(result * 100.0)) + \"%\"'\n\n    # enum → text + color\n    - id: ceph_health\n      query: ceph_health_status\n      valueExpr: 'result == 0.0 ? \"Healthy\" : result == 1.0 ? \"Warning\" : \"Critical\"'\n      colorExpr: 'result == 0.0 ? \"green\" : result == 1.0 ? \"orange\" : \"red\"'\n```\n\nBesides CEL's built-ins (arithmetic, comparisons, ternary `?:`, `in`) the environment enables:\n\n- the **`strings`** extension — `startsWith`, `matches`, `replace`, `substring`, `upperAscii`, …\n- the **`math`** extension — `math.round`, `math.abs`, `math.floor`/`ceil`, `math.least`/`greatest`\n  (clamping), and `math.isNaN`/`isInf`/`isFinite` to guard non-finite values (Prometheus returns\n  `NaN` for e.g. division by zero, which would otherwise render literally on the badge);\n- **optional types** — `labels[?\"k\"].orValue(\"default\")` for a label that may be absent.\n\nOn top of those, these formatting helpers are available (hand-rolled — kromgo has no external\nhumanize dependency, so the output is exactly as below):\n\n| Function                       | Example                           | Result    | Notes                                       |\n| ------------------------------ | --------------------------------- | --------- | ------------------------------------------- |\n| `humanizeBytes(result)`        | `humanizeBytes(1500000.0)`        | `1.5MB`   | SI decimal units (powers of 1000), no space |\n| `humanizeCommas(result)`       | `humanizeCommas(157121.0)`        | `157,121` | comma thousands grouping                    |\n| `humanizeFloat(result)`        | `humanizeFloat(2.50)`             | `2.5`     | plain decimal, trailing zeros stripped      |\n| `humanizeDuration(result)`     | `humanizeDuration(9000.0)`        | `2h30m`   | **seconds** → compact time span             |\n| `humanizeDurationDays(result)` | `humanizeDurationDays(5961600.0)` | `69d`     | **seconds** → whole days, no roll-up        |\n\n`humanizeDuration` takes **seconds** (so it drops onto a `time() - created_ts` query directly) and\nadapts to the magnitude, emitting the up-to-three most-significant units — `90` → `1m30s`, `9000` →\n`2h30m`, `40348800` → `1y3mo12d`. Months render as `mo` so they never collide with minutes (`m`) in\nthe same string.\n\nFor **coloring**, `colorScale(result, steps, colors)` maps a number to a shields.io color name, so a\n`colorExpr` doesn't need a hand-written chain of ternaries. It returns `colors[i]` at the first\n`result \u003c steps[i]`, otherwise the last color — so `colors` has one more entry than `steps`. Write the\nthresholds as **decimals** (`35.0`, not `35`); an integer literal fails to compile.\n\n```yaml\n# instead of\ncolorExpr: 'result \u003c 35 ? \"green\" : result \u003c 75 ? \"orange\" : \"red\"'\n# use\ncolorExpr: 'colorScale(result, [35.0, 75.0], [\"green\", \"orange\", \"red\"])'\n```\n\nFor a percentage — say red below 80, green by 100 — just list the cutoffs and their colors:\n\n```yaml\ncolorExpr: 'colorScale(result, [80.0, 90.0, 100.0], [\"red\", \"yellow\", \"green\", \"brightgreen\"])'\n```\n\nTwo gotchas around `result` (a `double`):\n\n- **Numeric literals.** Ordered comparisons accept plain integers — `result \u003c 35` works (kromgo\n  enables CEL's cross-type numeric comparisons). Equality and arithmetic do **not**: write a decimal\n  literal there, e.g. `result == 0.0` (not `== 0`) and `result * 100.0` (not `* 100`). A mismatch is\n  a compile error caught at startup, not a runtime surprise.\n- **Missing labels.** Indexing a label that isn't present errors. Use optional indexing —\n  `labels[?\"k\"].orValue(\"n/a\")` — or the ternary `\"k\" in labels ? labels[\"k\"] : \"n/a\"`.\n\n### Graphs\n\nEach entry under `graphs:` defines a time-series endpoint at `/graphs/{id}`. Defining a graph is the\nopt-in to expose range data for that query — there is no separate enable flag. Charts are rendered by\n[go-analyze/charts](https://github.com/go-analyze/charts) as **SVG** (default) or **PNG**\n(`?format=png`).\n\n| Field         | Required | Description                                                                           |\n| ------------- | -------- | ------------------------------------------------------------------------------------- |\n| `id`          | yes      | URL path segment — `cpu` → `GET /graphs/cpu`                                          |\n| `query`       | yes      | PromQL expression run as a range query                                                |\n| `title`       | no       | Display label (defaults to `id`)                                                      |\n| `maxDuration` | no       | Cap on the requested window (overrides `defaults.graph.maxDuration`)                  |\n| `width`       | no       | Image width in px (overrides `defaults.graph.width`)                                  |\n| `height`      | no       | Image height in px (overrides `defaults.graph.height`)                                |\n| `legend`      | no       | Show the series legend (overrides `defaults.graph.legend`)                            |\n| `fill`        | no       | Fill a translucent area beneath the line(s) (overrides `defaults.graph.fill`)         |\n| `theme`       | no       | Color theme (overrides `defaults.graph.theme`) — see [Themes](#themes-and-fonts)      |\n| `font`        | no       | Text font (overrides `defaults.graph.font`) — see [Themes](#themes-and-fonts)         |\n| `valueExpr`   | no       | CEL expression formatting the y-axis labels (overrides `defaults.graph.valueExpr`)    |\n| `yMin`/`yMax` | no       | Pin the y-axis range instead of auto-fitting (overrides `defaults.graph.yMin`/`yMax`) |\n| `markLine`    | no       | Dashed reference lines: any of `average`, `min`, `max`, `median` (first series only)  |\n| `gallery`     | no       | Per-graph gallery settings, e.g. `gallery: {hidden: true}` — see [Gallery](#gallery)  |\n\n```yaml\ngraphs:\n    - id: node_cpu_usage\n      query: \"cluster:node_cpu:ratio_rate5m * 100\"\n      maxDuration: \"30d\"\n      width: 800\n      theme: catppuccin-mocha\n```\n\nBy default the y-axis labels use the chart library's numeric formatting, which can show fractional\nticks (e.g. `42.8`) even when the underlying values are whole numbers. `valueExpr` overrides this:\nlike a badge's [`valueExpr`](#value-and-color), it's a CEL expression over `result` (here, the y-axis\ntick value) that returns the label string, with the same [humanizer functions](#value-and-color)\navailable. It formats **only the y-axis labels** — the legend shows series names, and `?format=json`\nkeeps the raw numbers.\n\n```yaml\ngraphs:\n    - id: cluster_pod_count_graph\n      title: Running Pods\n      query: sum(kube_pod_status_phase{phase=\"Running\"})\n      maxDuration: 7d\n      valueExpr: string(int(result)) + \" pods\" # integer ticks; drop the suffix for bare integers\n```\n\nFor axis context, pin the range with `yMin`/`yMax` (e.g. `yMin: 0`, `yMax: 100` for a percentage)\nrather than letting it auto-fit, and add dashed reference lines with `markLine` (`average`, `min`,\n`max`, or `median`). Mark lines are computed by the chart library — there's no static-threshold line,\nand they render for the first series only:\n\n```yaml\ngraphs:\n    - id: cluster_cpu_graph\n      title: CPU Usage\n      query: avg(cluster:node_cpu:ratio_rate5m) * 100\n      valueExpr: string(int(result)) + \"%\"\n      yMin: 0\n      yMax: 100\n      markLine: [average]\n```\n\nThe time window is chosen by these query parameters:\n\n| Parameter | Default    | Description                                                              |\n| --------- | ---------- | ------------------------------------------------------------------------ |\n| `last`    | —          | Shorthand window ending now, e.g. `last=7d` (supports `s/m/h/d/y` units) |\n| `start`   | end − 1h   | Window start — Unix timestamp or RFC3339                                 |\n| `end`     | now        | Window end — Unix timestamp or RFC3339                                   |\n| `step`    | window/100 | Resolution between points (min `1m`); supports `s/m/h/d/y` units         |\n\nThe rendering fields `width`, `height`, `legend`, `fill`, `yMin`/`yMax`, and `theme`, plus the output\n`format` (`svg`/`png`), may also be overridden per request via query parameters, e.g.\n`/graphs/node_cpu_usage?theme=dracula\u0026fill=true\u0026ymax=100\u0026format=png\u0026last=24h`. (`font`, `valueExpr`,\nand `markLine` are config-only — resolved/compiled once at startup.)\n\n#### Themes and fonts\n\n`theme` accepts a [go-analyze/charts](https://github.com/go-analyze/charts) built-in or one of\nkromgo's bundled palettes (an unknown value falls back to the default):\n\n- **Built-in:** `light` (default), `dark`, `vivid-light`, `vivid-dark`, `grafana`, `ant`,\n  `nature-light`, `nature-dark`, `retro`, `ocean`, `slate`, `gray`, `winter`, `spring`, `summer`,\n  `fall`.\n- **Bundled:** `catppuccin-latte`, `catppuccin-frappe`, `catppuccin-macchiato`, `catppuccin-mocha`\n  (via the official [catppuccin/go](https://github.com/catppuccin/go) palette), `dracula`, `monokai`,\n  `night-owl`.\n\n`font` accepts one of:\n\n- **`dejavu-sans`** (the default) / **`dejavu-sans-bold`** — the free, metric-compatible stand-in for the\n  Verdana that [shields.io](https://shields.io) renders with. Vendored via npm (`dejavu-fonts-ttf`).\n- **`comic-neue`** / **`comic-neue-bold`** — a free Comic Sans alternative (Google Fonts, via\n  `@expo-google-fonts/comic-neue`), for when a badge wants some personality.\n\nBoth faces are compiled in by `cmd/genassets` (kept current by Renovate). Badges and graphs default to\n`dejavu-sans` (shields.io-style — 11 px text, 20 px tall); set `font:` to opt into the others. Fonts are\ncompiled into the binary — there's no reading from disk, so add a face by vendoring it (npm) and PRing it\ninto the registry. An unknown name fails fast at startup.\n\n## Gallery\n\n`GET /` serves a gallery: a responsive page (up to three columns, collapsing to one on mobile) that\npreviews every visible badge and graph and shows the copy-pasteable Markdown snippet for each — the\npreview is rendered from that same snippet with [marked](https://github.com/markedjs/marked), so what\nyou see is what a GitHub README will show. Snippet URLs are absolute, built from the request host (a\nreverse proxy's `X-Forwarded-Proto` is honored for the scheme).\n\nThe page is self-contained: its JavaScript and CSS are embedded in the binary and served from\n`/assets/` — no external CDN — so it works air-gapped and keeps a strict `script-src 'self'`\nContent-Security-Policy. See [Building from source](#building-from-source) for how the assets are\nvendored.\n\n**Enable / disable.** The gallery is on by default. Turn it off with a top-level `gallery.enabled:\nfalse`, which serves a minimal landing page at `/` instead (the badge and graph endpoints are\nunaffected):\n\n```yaml\ngallery:\n    enabled: false\n```\n\n**Which endpoints appear.** Every endpoint is listed by default. Hide one with a per-endpoint\n`gallery.hidden: true`, or flip the default per type under `defaults.badge.gallery` /\n`defaults.graph.gallery`:\n\n```yaml\ndefaults:\n    badge:\n        gallery:\n            hidden: true # hide badges from the gallery by default…\nbadges:\n    - id: cpu\n      query: \"...\"\n      gallery:\n          hidden: false # …but list this one\n```\n\nWhen nothing is visible the gallery shows a short hint instead.\n\n## API reference\n\n| Route              | Default response        | Variants                                                           |\n| ------------------ | ----------------------- | ------------------------------------------------------------------ |\n| `GET /badges/{id}` | SVG badge (`?style=…`)  | `?format=shields` → shields.io JSON · `?format=json` → kromgo JSON |\n| `GET /graphs/{id}` | SVG chart (`?theme=…`)  | `?format=png` → PNG image · `?format=json` → time-series data      |\n| `GET /`            | HTML gallery            | landing page when `gallery.enabled: false`                         |\n| `GET /assets/…`    | Embedded gallery JS/CSS |                                                                    |\n\n**`/badges/{id}`** (default SVG):\n\n```html\n\u003cimg src=\"http://localhost:8080/badges/node_cpu_usage\" /\u003e\n```\n\n**`?format=shields`** — the [shields.io Endpoint Badge](https://shields.io/badges/endpoint-badge) schema:\n\n```json\n{ \"schemaVersion\": 1, \"label\": \"node_cpu_usage\", \"message\": \"17.5%\", \"color\": \"green\" }\n```\n\n**`?format=json`** — kromgo's native JSON (rendered string plus the raw number and labels):\n\n```json\n{\n    \"id\": \"node_cpu_usage\",\n    \"title\": \"CPU\",\n    \"value\": \"17.5%\",\n    \"color\": \"green\",\n    \"result\": 17.5,\n    \"labels\": {}\n}\n```\n\n**`/graphs/{id}?format=json`** — the raw time series:\n\n```json\n{\n    \"id\": \"node_cpu_usage\",\n    \"title\": \"CPU\",\n    \"start\": 1702578219,\n    \"end\": 1702664619,\n    \"step\": 60,\n    \"series\": [{ \"labels\": { \"instance\": \"node-1\" }, \"data\": [{ \"t\": 1702578219, \"v\": 17.5 }] }]\n}\n```\n\n## Ports\n\n| Port   | Purpose                                                        |\n| ------ | -------------------------------------------------------------- |\n| `8080` | Main server — badge and graph endpoints                        |\n| `8888` | Health server — `/healthz`, `/readyz`, `/metrics` (Prometheus) |\n\nThe health server's `/metrics` endpoint exposes Go runtime metrics plus\n`kromgo_requests_total{kind, id, format}` — a counter of requests handled, broken down by endpoint\nkind (`badge`/`graph`), id, and response format.\n\n## Rate limiting\n\nkromgo does not rate limit itself — it's meant to sit behind a reverse proxy on the public web, and\nproxies do this better (shared limits across replicas, per-IP buckets, burst handling, `429`\nresponses). Configure it there. Examples for limiting `/` traffic to kromgo on `:8080`:\n\n**nginx** — in the `http {}` block, then reference the zone in your `location`:\n\n```nginx\nlimit_req_zone $binary_remote_addr zone=kromgo:10m rate=10r/s;\n\nserver {\n    location / {\n        limit_req zone=kromgo burst=20 nodelay;\n        proxy_pass http://kromgo:8080;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n    }\n}\n```\n\n**Caddy** — requires the [caddy-ratelimit](https://github.com/mholt/caddy-ratelimit) module\n(`xcaddy build --with github.com/mholt/caddy-ratelimit`):\n\n```caddyfile\nkromgo.example.com {\n    rate_limit {\n        zone kromgo {\n            key    {remote_host}\n            events 10\n            window 1s\n        }\n    }\n    reverse_proxy kromgo:8080\n}\n```\n\n**Envoy** — the built-in [local rate limit](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/local_rate_limit_filter)\nHTTP filter (100 requests/minute per listener):\n\n```yaml\nhttp_filters:\n    - name: envoy.filters.http.local_ratelimit\n      typed_config:\n          \"@type\": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit\n          stat_prefix: kromgo_rate_limiter\n          token_bucket:\n              max_tokens: 100\n              tokens_per_fill: 100\n              fill_interval: 60s\n          filter_enabled:\n              default_value: { numerator: 100, denominator: HUNDRED }\n          filter_enforced:\n              default_value: { numerator: 100, denominator: HUNDRED }\n```\n\n**Traefik v3** — a `rateLimit` middleware attached to the router (dynamic file config; the\nKubernetes `Middleware` CRD takes the same `rateLimit` spec):\n\n```yaml\nhttp:\n    middlewares:\n        kromgo-ratelimit:\n            rateLimit:\n                average: 10\n                burst: 20\n                period: 1s\n    routers:\n        kromgo:\n            rule: Host(`kromgo.example.com`)\n            service: kromgo\n            middlewares:\n                - kromgo-ratelimit\n```\n\n## Caching\n\nCaching has two halves. kromgo owns the half only it can know — **how long a value stays fresh** —\nand emits a `Cache-Control` header so the other half (a browser, CDN, or GitHub's camo image proxy)\nknows how long to store the response. One policy applies to every endpoint; it is configured at the\ntop level under `cache:` and is **enabled by default**.\n\n```yaml\ncache:\n    enabled: true # default; false sends no-store so nothing caches the badge\n    maxAge: 300 # max-age + s-maxage in seconds (default 300); ignored when disabled\n```\n\n- **`enabled: true` (default)** — kromgo sends `Cache-Control: public, max-age=\u003cmaxAge\u003e, s-maxage=\u003cmaxAge\u003e`\n  on successful responses and advertises `cacheSeconds` in the shields.io endpoint JSON. `max-age`\n  governs browser caches; `s-maxage` governs shared caches (CDNs, camo) — shields.io sets both.\n- **`enabled: false`** — kromgo sends `Cache-Control: no-cache, no-store, must-revalidate, max-age=0`.\n  Sending _no_ header is not the same as disabling caching: it lets camo/CDNs apply their own\n  aggressive default (which is why an unconfigured badge can go stale), so kromgo always sends an\n  explicit header. To turn caching off set `enabled: false` — not `maxAge: 0`, which just falls back\n  to the 300s default.\n\nErrors are always sent `no-store`. A `Cache-Control` header still isn't a hard guarantee against\nGitHub's camo proxy ([shields#221](https://github.com/badges/shields/issues/221)), but it's the\nstrongest signal kromgo can send.\n\nThe **other half — actually storing responses — is the edge's job**, and any cache that honors\n`Cache-Control` (a CDN, Varnish, nginx `proxy_cache`) will then cache each endpoint for the advertised\n`maxAge`. shields.io already respects `cacheSeconds`, so badges served through it are cached without\nany proxy at all.\n\nIf you want the reverse proxy itself to cache, enable its HTTP cache and let it honor the origin\nheaders — for example, nginx:\n\n```nginx\nproxy_cache_path /var/cache/nginx levels=1:2 keys_zone=kromgo:10m max_size=100m;\n\nserver {\n    location / {\n        proxy_cache kromgo;            # respects kromgo's Cache-Control\n        add_header X-Cache-Status $upstream_cache_status;\n        proxy_pass http://kromgo:8080;\n    }\n}\n```\n\nCaddy (via the [cache-handler](https://github.com/caddyserver/cache-handler) plugin), Traefik, and\nEnvoy can cache too, but generally need a plugin or an external cache/CDN; the simplest setup is to\nfront kromgo with a CDN and let kromgo's `Cache-Control` header drive it.\n\n## Security\n\nkromgo is built to face the public web. Its posture:\n\n- **Prometheus is never exposed.** Only the endpoints you define are reachable; query parameters are\n  parsed as durations/timestamps/enums and never interpolated into PromQL.\n- **SVG output is safe.** Badge text and graph labels (which can derive from attacker-influenceable\n  metric label values) are HTML-escaped, and badge/graph/JSON responses carry\n  `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'` and\n  `X-Content-Type-Options: nosniff`, so an SVG can't execute script even when opened directly.\n- **The gallery loads nothing external.** Its JS/CSS are embedded and served from `/assets/`, so the\n  page ships a tightened-but-still-locked-down CSP (`script-src 'self'`, no `unsafe-inline`/`unsafe-eval`,\n  no CDN). The Host header used to build snippet URLs is validated before use.\n- **Bounded work.** Each Prometheus query is bounded by `QUERY_TIMEOUT` (default 30s); graph windows\n  are capped by `maxDuration` and image dimensions are clamped. A 10s `ReadHeaderTimeout` guards\n  against Slowloris; tune `SERVER_READ_TIMEOUT`/`SERVER_WRITE_TIMEOUT` to your proxy.\n- **Minimal image.** A `scratch` image with just the static binary and a CA bundle (kromgo dials\n  Prometheus over HTTPS) — no shell, package manager, or writable filesystem. It pins no user; set one\n  via your Kubernetes `securityContext` or `docker run --user`. Images are cosign-signed (below).\n\nOperational guidance:\n\n- **Expose only the main port (`8080`).** The health port (`8888`) serves `/metrics` and probes —\n  keep it on the internal network.\n- **Terminate TLS and rate limit at your reverse proxy** (see [Rate limiting](#rate-limiting)).\n- Treat the config as trusted (it's operator-controlled). Fonts are compiled-in (never read from\n  disk), and CEL expressions run sandboxed (no env/file/network access).\n\n## Image verification\n\nImages are built and [Cosign](https://docs.sigstore.dev/cosign/overview/)-signed (keyless) by the\nofficial [`docker/github-builder`](https://github.com/docker/github-builder) reusable workflow, so the\nsigning identity is that workflow rather than this repo. Verify an image before running it:\n\n```bash\ncosign verify ghcr.io/home-operations/kromgo:\u003ctag\u003e \\\n  --certificate-identity-regexp=\"^https://github.com/docker/github-builder/.github/workflows/build.yml@\" \\\n  --certificate-oidc-issuer=\"https://token.actions.githubusercontent.com\"\n```\n\nThe exact `cosign verify` command (with the pinned builder ref) is also printed in each build run's\nsummary.\n\n## Building from source\n\nThe gallery's `marked.js` / `github-markdown.css` and the full Material Design Icons and Simple Icons\nsets are vendored via npm (`package.json` + `package-lock.json`) and baked into the binary with\n`//go:embed` rather than committed. [`cmd/genassets`](cmd/genassets/main.go) reads `node_modules` and\nwrites the embedded files, so a build runs `npm ci` once (network) and the resulting binary is\nself-contained (nothing fetched at runtime).\n\n```bash\nmise run assets   # npm ci + go run ./cmd/genassets (re-runs only when the lockfile changes)\ngo build ./cmd/kromgo\n```\n\n`mise run test` / `lint` / `test-e2e` depend on `assets`, so they build it automatically; CI and the\nDocker build (a dedicated `node` stage) do the same. [Renovate](https://docs.renovatebot.com) keeps\n`marked`, `github-markdown-css`, `@mdi/svg`, and `simple-icons` current via PRs against `package.json`.\n\n## Upgrading 0.11 → 0.12\n\n0.12 splits the flat `metrics:` list into `badges:` and `graphs:` sections, with REST-style routes.\nA pre-0.12 config fails fast at startup with a pointer to this guide.\n\n| Change                                                                                                                                         | Action                                                                                                                                           |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |\n| **`metrics:` split into `badges:` and `graphs:`.** Instant-value endpoints go under `badges:`; time-series endpoints under `graphs:`.          | Move each metric to the section(s) it needs. A metric you served as both a badge and a chart becomes one entry in each (with the same `id`).     |\n| **`name` → `id`.**                                                                                                                             | Rename the key on every endpoint.                                                                                                                |\n| **Routes are namespaced.** `GET /{name}` + `?format=` → `GET /badges/{id}` and `GET /graphs/{id}`.                                             | Update embed URLs and shields.io endpoint URLs.                                                                                                  |\n| **Badge default is now the SVG image.** `?format=badge` → default; `?format=json` (shields schema) → `?format=shields`; `?format=raw` removed. | Embed `/badges/{id}` directly; point shields.io at `?format=shields`. `?format=json` now returns kromgo's native JSON (value + result + labels). |\n| **Graph formats.** `?format=chart` → `/graphs/{id}` (SVG default); `?format=history` → `/graphs/{id}?format=json`.                             | Switch to the `/graphs/` routes.                                                                                                                 |\n| **`defaults.timeseries` removed.** The `enabled` gate is gone — defining a `graphs:` entry _is_ the opt-in.                                    | Drop `timeseries.enabled`; move `maxDuration` to `defaults.graph.maxDuration` or per-graph `maxDuration`.                                        |\n| **Global `badge:` (font/size) → `defaults.badge`.** Badge `style` is now a config field too.                                                   | Move `badge.font`/`badge.size` under `defaults.badge`.                                                                                           |\n\nRelease tags drop the `v` prefix (e.g. `0.12.0`, not `v0.12.0`); pin image tags accordingly.\n\n## Upgrading from kashalls/kromgo\n\nThis fork began as [kashalls/kromgo](https://github.com/kashalls/kromgo). Beyond the schema changes\nabove, note: the image moved to `ghcr.io/home-operations/kromgo`; the badge font is no longer bundled\n(an embedded font is used, with `defaults.badge.font` to override); `LOG_FORMAT=test` was corrected\nto `LOG_FORMAT=text`; built-in rate limiting was removed (see [Rate limiting](#rate-limiting)); and a\nmissing `PROMETHEUS_URL` now fails fast instead of starting degraded.\n\n## Community\n\nThanks to everyone in the [Home Operations](https://discord.gg/home-operations) Discord community.\nThis project began as [kashalls/kromgo](https://github.com/kashalls/kromgo).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhome-operations%2Fkromgo","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhome-operations%2Fkromgo","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhome-operations%2Fkromgo/lists"}