Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,25 @@ So I decided to recreate it.
- `GET /progress/{percentage}`
- `HEAD /progress/{percentage}`

`percentage` must be an integer and is clamped to `0..100`.
`percentage` can be an integer or float and is clamped to `0..100`.

### Query params

- `dangerColor`
- `warningColor`
- `successColor`
- `barColor` (overrides the bar fill color)
- `label` (custom text inside the bar, max 64 chars)
- `min`
- `max`

All color values must be 6-character hex values without `#` (example: `ff9900`).
`min` and `max` must be provided together when used.

### Response behavior

- `200 OK`: valid request, returns SVG.
- `400 Bad Request`: invalid percentage or color format.
- `400 Bad Request`: invalid numeric input, range config, label length, or color format.
- `405 Method Not Allowed`: any method different from `GET` or `HEAD`.
- `500 Internal Server Error`: template parse/render failure.

Expand All @@ -60,6 +65,10 @@ Headers for successful responses:

![](https://geps.dev/progress/75)

Float percentage:

![](https://geps.dev/progress/76.5)

### Custom colors

You can customize colors through query params:
Expand All @@ -80,6 +89,14 @@ Rendered examples:

![](https://geps.dev/progress/75?dangerColor=800000&warningColor=ff9900&successColor=006600)

### Data bar mode (from arbitrary numeric range)

```md
![](https://geps.dev/progress/186?label=186&min=0&max=241&barColor=4472C4)
```

![](https://geps.dev/progress/186?label=186&min=0&max=241&barColor=4472C4)

## 🛠️ Local Development

### Prerequisites
Expand Down
106 changes: 90 additions & 16 deletions progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package progress
import (
"bytes"
"log"
"math"
"net/http"
"os"
"path"
Expand All @@ -11,24 +12,26 @@ import (
"strings"
"text/template"
"time"
"unicode/utf8"

"github.com/GoogleCloudPlatform/functions-framework-go/functions"
)

// Data ... is the collection of inputs we need to fill our template
type Data struct {
BackgroundColor string
Percentage int
Label string
Progress int
PickedColor string
}

const (
gcloudFuncSourceDir = "serverless_function_source_code"
minPercentage = 0
maxPercentage = 100
totalBarWidth = 90
minPercentage = 0.0
maxPercentage = 100.0
totalBarWidth = 90.0
cacheControlValue = "public, max-age=300"
maxLabelRunes = 64
)

var (
Expand All @@ -49,7 +52,7 @@ func fixDir() {
}
}

func clampPercentage(percentage int) int {
func clampPercentage(percentage float64) float64 {
if percentage < minPercentage {
return minPercentage
}
Expand All @@ -61,8 +64,8 @@ func clampPercentage(percentage int) int {
return percentage
}

func percentageToWidth(percentage int) int {
return (totalBarWidth * percentage) / maxPercentage
func percentageToWidth(percentage float64) int {
return int((totalBarWidth * percentage) / maxPercentage)
}

func parseOptionalColor(raw string) (string, bool) {
Expand All @@ -77,7 +80,7 @@ func parseOptionalColor(raw string) (string, bool) {
return "#" + strings.ToLower(raw), true
}

func pickColor(percentage int, successColor string, warningColor string, dangerColor string) string {
func pickColor(percentage float64, successColor string, warningColor string, dangerColor string) string {
pickedColor := green
if successColor != "" {
pickedColor = successColor
Expand All @@ -100,6 +103,21 @@ func pickColor(percentage int, successColor string, warningColor string, dangerC
return pickedColor
}

func formatNumber(value float64) string {
formatted := strconv.FormatFloat(value, 'f', 2, 64)
formatted = strings.TrimRight(formatted, "0")
formatted = strings.TrimRight(formatted, ".")
if formatted == "-0" || formatted == "" {
return "0"
}

return formatted
}

func formatPercentLabel(value float64) string {
return formatNumber(value) + "%"
}

func init() {
fixDir()
progressTemplate, progressTemplateErr = template.ParseFiles("progress.html")
Expand Down Expand Up @@ -135,16 +153,14 @@ func Progress(w http.ResponseWriter, r *http.Request) {
}

id := path.Base(strings.TrimSuffix(r.URL.Path, "/"))
percentage, err := strconv.Atoi(id)
if err != nil {
value, err := strconv.ParseFloat(id, 64)
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
statusCode = http.StatusBadRequest
http.Error(w, "percentage must be an integer", statusCode)
http.Error(w, "percentage must be a number", statusCode)
return
}

percentage = clampPercentage(percentage)

// Read and validate success, warning, and danger colors if provided.
// Read and validate colors if provided.
successColor, ok := parseOptionalColor(r.URL.Query().Get("successColor"))
if !ok {
statusCode = http.StatusBadRequest
Expand All @@ -166,11 +182,69 @@ func Progress(w http.ResponseWriter, r *http.Request) {
return
}

barColor, ok := parseOptionalColor(r.URL.Query().Get("barColor"))
if !ok {
statusCode = http.StatusBadRequest
http.Error(w, "barColor must be a 6-character hex value", statusCode)
return
}

customLabel := r.URL.Query().Get("label")
if utf8.RuneCountInString(customLabel) > maxLabelRunes {
statusCode = http.StatusBadRequest
http.Error(w, "label is too long (max 64 characters)", statusCode)
return
}

minRaw := r.URL.Query().Get("min")
maxRaw := r.URL.Query().Get("max")
hasMin := minRaw != ""
hasMax := maxRaw != ""

if hasMin != hasMax {
statusCode = http.StatusBadRequest
http.Error(w, "min and max must be provided together", statusCode)
return
}

percentage := clampPercentage(value)
if hasMin {
minValue, minErr := strconv.ParseFloat(minRaw, 64)
maxValue, maxErr := strconv.ParseFloat(maxRaw, 64)
if minErr != nil || maxErr != nil || math.IsNaN(minValue) || math.IsNaN(maxValue) || math.IsInf(minValue, 0) || math.IsInf(maxValue, 0) {
statusCode = http.StatusBadRequest
http.Error(w, "min and max must be numeric values", statusCode)
return
}

if maxValue <= minValue {
statusCode = http.StatusBadRequest
http.Error(w, "max must be greater than min", statusCode)
return
}

normalized := ((value - minValue) / (maxValue - minValue)) * maxPercentage
percentage = clampPercentage(normalized)
}

pickedColor := pickColor(percentage, successColor, warningColor, dangerColor)
if barColor != "" {
pickedColor = barColor
}

label := formatPercentLabel(percentage)
if hasMin {
label = formatNumber(value)
}
if customLabel != "" {
label = customLabel
}

data := Data{
BackgroundColor: grey,
Percentage: percentage,
Label: label,
Progress: percentageToWidth(percentage),
PickedColor: pickColor(percentage, successColor, warningColor, dangerColor),
PickedColor: pickedColor,
}

buf := new(bytes.Buffer)
Expand Down
2 changes: 1 addition & 1 deletion progress.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<rect rx="4" width="90.0" height="20" fill="url(#a)"/>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="45.0" y="14">
{{.Percentage}}%
{{.Label}}
</text>
</g>
</svg>
100 changes: 99 additions & 1 deletion progress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,51 @@ func TestProgressReturnsSVGForValidInput(t *testing.T) {
}
}

func TestProgressSupportsFloatPercentages(t *testing.T) {
rr := executeProgressRequest("/progress/76.5")
if rr.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rr.Code)
}

body := rr.Body.String()
if !strings.Contains(body, "76.5%") {
t.Fatalf("expected decimal percentage in body, got %q", body)
}
if !strings.Contains(body, `width="68" height="20" fill="#5cb85c"`) {
t.Fatalf("expected decimal percentage width/color in SVG body, got %q", body)
}
}

func TestProgressSupportsDataBarMode(t *testing.T) {
rr := executeProgressRequest("/progress/186?label=186&min=0&max=241&barColor=4472C4")
if rr.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rr.Code)
}

body := rr.Body.String()
if !strings.Contains(body, "186") {
t.Fatalf("expected custom label in body, got %q", body)
}
if !strings.Contains(body, `width="69" height="20" fill="#4472c4"`) {
t.Fatalf("expected scaled width/custom color in body, got %q", body)
}
}

func TestProgressDataBarDefaultsLabelToRawValue(t *testing.T) {
rr := executeProgressRequest("/progress/50?min=0&max=200")
if rr.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rr.Code)
}

body := rr.Body.String()
if !strings.Contains(body, "50\n") || strings.Contains(body, "50%") {
t.Fatalf("expected raw value label in body, got %q", body)
}
if !strings.Contains(body, `width="22" height="20" fill="#d9534f"`) {
t.Fatalf("expected scaled width and threshold color in body, got %q", body)
}
}

func TestProgressSupportsHEAD(t *testing.T) {
rr := executeProgressRequestWithMethod(http.MethodHead, "/progress/76")

Expand Down Expand Up @@ -116,7 +161,7 @@ func TestProgressReturnsBadRequestForInvalidPercentage(t *testing.T) {
t.Fatalf("expected status 400, got %d", rr.Code)
}

if !strings.Contains(rr.Body.String(), "percentage must be an integer") {
if !strings.Contains(rr.Body.String(), "percentage must be a number") {
t.Fatalf("expected invalid percentage message, got %q", rr.Body.String())
}
}
Expand All @@ -133,6 +178,59 @@ func TestProgressReturnsBadRequestForInvalidColor(t *testing.T) {
}
}

func TestProgressReturnsBadRequestForInvalidBarColor(t *testing.T) {
rr := executeProgressRequest("/progress/50?barColor=nothex")

if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rr.Code)
}

if !strings.Contains(rr.Body.String(), "barColor must be a 6-character hex value") {
t.Fatalf("expected invalid bar color message, got %q", rr.Body.String())
}
}

func TestProgressRequiresMinAndMaxTogether(t *testing.T) {
rr := executeProgressRequest("/progress/50?min=0")
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "min and max must be provided together") {
t.Fatalf("expected min/max pair message, got %q", rr.Body.String())
}
}

func TestProgressRejectsInvalidMinMaxValues(t *testing.T) {
rr := executeProgressRequest("/progress/50?min=abc&max=100")
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "min and max must be numeric values") {
t.Fatalf("expected min/max numeric message, got %q", rr.Body.String())
}
}

func TestProgressRejectsInvalidRangeOrder(t *testing.T) {
rr := executeProgressRequest("/progress/50?min=10&max=10")
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "max must be greater than min") {
t.Fatalf("expected max greater than min message, got %q", rr.Body.String())
}
}

func TestProgressRejectsTooLongLabel(t *testing.T) {
tooLongLabel := strings.Repeat("x", 65)
rr := executeProgressRequest("/progress/50?label=" + tooLongLabel)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "label is too long") {
t.Fatalf("expected label length message, got %q", rr.Body.String())
}
}

func TestProgressClampsPercentageToRange(t *testing.T) {
high := executeProgressRequest("/progress/150")
if high.Code != http.StatusOK {
Expand Down