Initial commit

This commit is contained in:
Óscar M. Lage 2024-12-05 14:00:58 +01:00
commit b681bf6d1c
26 changed files with 62828 additions and 0 deletions

8
.air.toml Normal file
View File

@ -0,0 +1,8 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main_web ./cmd/web/main.go"
bin = "./tmp/main_web"
delay = 1000

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.env
tmp
results/*.json

28
Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM golang:1.21.4
# Set default build-time value, not persisted in the container
ARG DOCKER_ENV=production
# Set runtime environment variable inside the container
ENV DOCKER_ENV=${DOCKER_ENV}
WORKDIR /code
RUN echo "Building with DOCKER_ENV=${DOCKER_ENV}"
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go mod tidy && \
if [ "$DOCKER_ENV" = "local" ]; then \
echo "Local environment detected, installing Air..."; \
go install github.com/cosmtrek/air@v1.49.0; \
fi
CMD if [ "$DOCKER_ENV" = "local" ]; then \
echo "Local environment detected, running Air..." && \
air -c .air.toml; \
else \
echo "Production environment detected, building and running the web..."; \
go build -o tmp/web ./cmd/web/main.go && ./tmp/web; \
fi

220
Makefile Normal file
View File

@ -0,0 +1,220 @@
##
# Makefile to help manage docker-compose services
#
# Include environment files
include .env
export
# Variables
THIS_FILE := $(lastword $(MAKEFILE_LIST))
DOCKER := $(shell which docker)
DOCKER_COMPOSE := $(shell which docker-compose)
IMAGE_DEFAULT := wrappdsh
CONTAINER_DEFAULT := wrappdsh
SHELL_CMD := /bin/bash
# Docker compose files
DOCKER_COMPOSE_FILES := -f docker-compose.yml
ifeq ($(DOCKER_ENV),local)
DOCKER_COMPOSE_FILES := -f docker-compose.yml
endif
ifeq ($(DOCKER_ENV),production)
DOCKER_COMPOSE_FILES := -f docker-compose.yml
endif
# Services
SERVICES_DEFAULT := web
ifeq ($(DOCKER_ENV),local)
SERVICES_DEFAULT := web
endif
ifeq ($(DOCKER_ENV),production)
SERVICES_DEFAULT := web
endif
SERVICE_DEFAULT := web
container ?= $(CONTAINER_DEFAULT)
image ?= $(IMAGE_DEFAULT)
service ?=
services ?= $(SERVICES_DEFAULT)
.DEFAULT_GOAL := help
##
# help
#
help:
ifeq ($(CONTAINER_DEFAULT),)
$(warning WARNING: CONTAINER_DEFAULT is not set. Please edit makefile)
endif
@echo
@echo "Make targets:"
@echo
@cat $(THIS_FILE) | \
sed -n -E 's/^([^.][^: ]+)\s*:(([^=#]*##\s*(.*[^[:space:]])\s*)|[^=].*)$$/ \1 \4/p' | \
sort -u | \
expand -t15
@echo
@echo
@echo "Target arguments:"
@echo
@echo " " "service" "\t" "Target service for docker-compose actions (default=all-services)"
@echo " " " " "\t" " - make start"
@echo " " " " "\t" " - make start service=app"
@echo " " "services" "\t" "Target services for docker-compose actions (default=all-services, space separated)"
@echo " " " " "\t" " - make stop services='app db'"
@echo " " "container""\t" "Target container for docker actions (default='$(CONTAINER_DEFAULT)')"
@echo " " " " "\t" " - make bash container=$(container)"
@echo " " "image" "\t" "Target image for interactive shell (default='$(IMAGE_DEFAULT)')"
@echo " " " " "\t" " - make it image=$(image)"
##
# services
#
services: ## Lists services
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) ps --services
##
# start
#
all: dev ## See 'dev'
start: dev ## See 'dev'
dev: ## Start containers for development [service|services]
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) up -d $(services)
$(MAKE) logs
##
# stop
#
stop: ## Stop containers [service|services]
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) stop $(services)
##
# restart
#
restart: ## Restart containers [service|services]
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) restart $(services)
##
# down
#
down: ## Removes containers (preserves images and volumes)
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) down
##
# build
#
build: ## Builds service images [service|services]
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) build --build-arg DOCKER_ENV=${DOCKER_ENV} $(services)
##
# rebuild
#
rebuild: ## Build containers without cache [service|services]
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) build --build-arg DOCKER_ENV=${DOCKER_ENV} --no-cache $(services)
##
# ps
#
status: ps ## See 'ps'
ps: ## Show status of containers [service|services]
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) ps $(services)
##
# interact
#
interact: it ## See `it'
it: ## Run a new container in interactive mode. Needs image name [image]
ifeq ($(image),)
$(error ERROR: 'image' is not set. Please provide 'image=' argument or edit makefile and set CONTAINER_DEFAULT)
endif
@echo
@echo "Starting interactive shell ($(SHELL_CMD)) in image container '$(image)'"
@$(DOCKER) run -it --entrypoint "$(SHELL_CMD)" $(image)
##
# bash
#
sh: bash ## See 'bash'
shell: bash ## See 'bash'
bash: ## Brings up a shell in default (or specified) container [container]
ifeq ($(container),)
$(error ERROR: 'container' is not set. Please provide 'container=' argument or edit makefile and set CONTAINER_DEFAULT)
endif
@echo
@echo "Starting shell ($(SHELL_CMD)) in container '$(container)'"
@$(DOCKER) exec -it "$(container)" "$(SHELL_CMD)"
##
# attach
#
at: attach ## See 'attach'
attach: ## Attach to a running container [container]
ifeq ($(container),)
$(error ERROR: 'container' is not set. Please provide 'container=' argument or edit makefile and set CONTAINER_DEFAULT)
endif
@echo
@echo "Attaching to '$(container)'"
@$(DOCKER) attach $(container)
##
# log
#
log: ## Shows log from a specific container (in 'follow' mode) [container]
ifeq ($(container),)
$(error ERROR: 'container' is not set. Please provide 'container=' argument or edit makefile and set CONTAINER_DEFAULT)
endif
@echo
@echo "Log in $(container)"
@$(DOCKER) logs -f $(container)
##
# logs
#
logs: ## Shows output of running containers (in 'follow' mode) [service|services]
@echo
@echo "Logs in $(services)"
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) logs -f $(services)
##
# rmimages
#
rmimages: ## Remove images
@echo
@echo "Remove local images"
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) down --rmi local
##
# clean
#
clean: ## Remove containers, images and volumes
@echo
@echo "Remove containers, images and volumes"
@$(DOCKER_COMPOSE) $(DOCKER_COMPOSE_FILES) down --volumes --rmi all
%:
@:
.PHONY: %

70
README.md Normal file
View File

@ -0,0 +1,70 @@
# wrappd.sh
Wrapped tool for the guys living in a b&w world.
## Config
Copy the `env.sample` in `.env` and set up the variables:
```sh
$ cp env.sample .env
```
```ini
# Docker
DOCKER_ENV=production
PROJECT_NAME=heating-monitor
FIXME
```
## Docker
This project can be easily run on Docker:
```sh
$ make build
$ make rebuild
$ make start
```
The `Makefile` has been configured to facilitate the build and execution of Docker with the `--build-arg` argument, which passes the `DOCKER_ENV` environment variable to determine the environment. This variable is read from the .env file:
`--build-arg DOCKER_ENV=${DOCKER_ENV}`: This variable is used to determine the execution environment (`local` or `production`), affecting both the binary build process and the container's behavior.
If `DOCKER_ENV` is set to `production` it should execute the binary, whereas if set to `local` it will run with air to enable reloads during development, among other things.
## Comands
```bash
$ go run cmd/web/main.go
```
## Dependencies
Ensure your dependencies are installed:
```sh
$ go mod tidy
```
## Skel project
```sh
.
├── cmd/
│ ├── web/ # Command for the web to init
├── internal/
│ ├── web/ # Web logic (routes, controller...)
├── result/ # Place for storing the results
├── upload/ # Temporal place for storing the uploads (inmediately removed)
├── .env.sample # Sample environment file
└── README.md # This file
└── Dockerfile # Requirement for the docker container to be built
└── docker-compose.yml # Docker orchestration file
└── Makefile # Helper for the project to be run/build/stop...
```
## Contribs
TBD

3687
assets/animate.min.css vendored Normal file

File diff suppressed because it is too large Load Diff

43173
assets/echarts.min.js vendored Normal file

File diff suppressed because it is too large Load Diff

BIN
assets/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

BIN
assets/logo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

15061
assets/tailwind.js Normal file

File diff suppressed because it is too large Load Diff

33
cmd/web/main.go Normal file
View File

@ -0,0 +1,33 @@
package main
import (
"log"
"net/http"
"os"
"wrapped-shell/internal/web"
"github.com/joho/godotenv"
)
func main() {
// Load .env file
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
// Server config
port := os.Getenv("SERVER_PORT")
if port == "" {
port = "8080"
}
// Start server
http.HandleFunc("/", web.Routes)
log.Printf("Server running on port %s...", port)
err = http.ListenAndServe(":"+port, nil)
if err != nil {
log.Fatalf("Error starting server: %s", err)
}
}

19
docker-compose.yml Normal file
View File

@ -0,0 +1,19 @@
name: "wrappd-sh"
version: "3.8"
services:
web:
container_name: wrappdsh
hostname: wrappdsh
env_file:
- ./.env
build:
context: .
dockerfile: Dockerfile
volumes:
- ./:/code/
stdin_open: true
tty: true
ports:
- ${SERVER_PORT}:${SERVER_PORT}
restart: always

5
env-sample Normal file
View File

@ -0,0 +1,5 @@
DOCKER_ENV=production
BASE_URL=https://wrap.sh
SERVER_PORT=8080
UPLOAD_DIR=uploads
TOP_N_COMMANDS=10

9
go.mod Normal file
View File

@ -0,0 +1,9 @@
module wrapped-shell
go 1.21.4
require (
github.com/go-echarts/go-echarts/v2 v2.4.5
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
)

14
go.sum Normal file
View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-echarts/go-echarts/v2 v2.4.5 h1:gwDqxdi5x329sg+g2ws2OklreJ1K34FCimraInurzwk=
github.com/go-echarts/go-echarts/v2 v2.4.5/go.mod h1:56YlvzhW/a+du15f3S2qUGNDfKnFOeJSThBIrVFHDtI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho=
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

130
internal/web/controller.go Normal file
View File

@ -0,0 +1,130 @@
package web
import (
"io"
"net/http"
"os"
"strconv"
"strings"
"github.com/google/uuid"
)
func handleIndex(w http.ResponseWriter, r *http.Request) {
tmpl, err := loadTemplates("index.html")
if err != nil {
http.Error(w, "Error loading template", http.StatusInternalServerError)
return
}
tmpl.Execute(w, nil)
}
func handleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "Error reading file", http.StatusBadRequest)
return
}
defer file.Close()
// Save temp file
uploadDir := os.Getenv("UPLOAD_DIR")
if uploadDir == "" {
uploadDir = "uploads"
}
os.MkdirAll(uploadDir, os.ModePerm)
tempFile, err := os.CreateTemp(uploadDir, "history-*.txt")
if err != nil {
http.Error(w, "Error saving file", http.StatusInternalServerError)
return
}
defer tempFile.Close()
_, err = io.Copy(tempFile, file)
if err != nil {
http.Error(w, "Error copying file", http.StatusInternalServerError)
return
}
commandCounts, categories, pipeRedirectionCounts, commonPatterns := ProcessHistory(tempFile.Name())
err = os.Remove(tempFile.Name())
if err != nil {
http.Error(w, "Error deleting temporary file", http.StatusInternalServerError)
return
}
limit := os.Getenv("TOP_N_COMMANDS")
limitInt, err := strconv.Atoi(limit)
if err != nil {
limitInt = 5
}
// Stats
commandStats := ConvertAndSort(commandCounts, limitInt)
// Graph generation
bar := createBarChart(commandStats)
var chartHTML strings.Builder
if err := bar.Render(&chartHTML); err != nil {
http.Error(w, "Error generating chart", http.StatusInternalServerError)
return
}
// More stats
stats := GenerateStats(commandCounts, categories, commonPatterns)
stats["chartHTML"] = chartHTML.String()
// Categories
categoryStats := ConvertAndSort(categories, limitInt)
stats["categoryStats"] = categoryStats
// Top commands
topCommands := ConvertAndSort(commandCounts, limitInt)
stats["topCommands"] = topCommands
// Common patterns
commonPatternStats := ConvertAndSort(commonPatterns, limitInt)
stats["commonPatternStats"] = commonPatternStats
// Pipe counts
stats["pipeRedirectionCounts"] = pipeRedirectionCounts
// Save results
uniqueID := uuid.New().String()
saveResults(uniqueID, stats)
http.Redirect(w, r, "/results/"+uniqueID, http.StatusSeeOther)
}
func handleResults(w http.ResponseWriter, r *http.Request) {
uniqueID := strings.TrimPrefix(r.URL.Path, "/results/")
stats, err := loadResults(uniqueID)
if err != nil {
http.Error(w, "Results not found", http.StatusNotFound)
return
}
stats["uniqueID"] = uniqueID
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://wrappd.oscarmlage.com"
}
stats["baseURL"] = baseURL
tmpl, err := loadTemplates("result.html")
if err != nil {
http.Error(w, "Error loading template", http.StatusInternalServerError)
return
}
tmpl.Execute(w, stats)
}

70
internal/web/model.go Normal file
View File

@ -0,0 +1,70 @@
package web
import (
"bufio"
"log"
"os"
"regexp"
"strings"
)
type CommandStat struct {
Command string
Count int
}
func ProcessHistory(fileName string) (map[string]int, map[string]int, map[string]int, map[string]int) {
commandCounts := make(map[string]int)
categories := make(map[string]int)
pipeRedirectionCounts := make(map[string]int)
commonPatterns := make(map[string]int)
re := regexp.MustCompile(`\S+\.(log|txt|conf|json)`)
file, err := os.Open(fileName)
if err != nil {
log.Fatal(err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
command := scanner.Text()
if command == "" {
continue
}
commandCounts[command]++
if strings.Contains(command, "git") {
categories["Git"]++
} else if strings.Contains(command, "docker") {
categories["Docker"]++
} else if strings.Contains(command, "ssh") {
categories["SSH"]++
}
if strings.Contains(command, "|") {
pipeRedirectionCounts["pipe"]++
}
if strings.Contains(command, ">") || strings.Contains(command, "<") || strings.Contains(command, ">>") {
pipeRedirectionCounts["redirection"]++
}
matches := re.FindAllString(command, -1)
for _, match := range matches {
commonPatterns[match]++
}
}
return commandCounts, categories, pipeRedirectionCounts, commonPatterns
}
func GenerateStats(commandCounts map[string]int, categories map[string]int, commonPatterns map[string]int) map[string]interface{} {
stats := map[string]interface{}{
"topCommands": commandCounts,
"categories": categories,
"commonPatterns": commonPatterns,
}
return stats
}

20
internal/web/routes.go Normal file
View File

@ -0,0 +1,20 @@
package web
import (
"net/http"
"strings"
)
func Routes(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
handleIndex(w, r)
} else if r.URL.Path == "/upload" {
handleUpload(w, r)
} else if strings.HasPrefix(r.URL.Path, "/results/") {
handleResults(w, r)
} else if strings.HasPrefix(r.URL.Path, "/static/") {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("assets"))))
} else {
http.NotFound(w, r)
}
}

View File

@ -0,0 +1,19 @@
{{ template "header.html" . }}
<div class="container flex justify-center items-center">
<div class="bg-gray-800 p-8 rounded-lg shadow-xl max-w-md w-full">
<h1 class="text-3xl text-center text-white mb-6 font-semibold">Bash Wrapped</h1>
<form action="/upload" method="post" enctype="multipart/form-data" class="space-y-4">
<div class="flex justify-center">
<input type="file" name="file" class="p-3 text-white bg-gray-700 rounded-md border-2 border-gray-600 w-full" />
</div>
<div class="flex justify-center">
<button type="submit" class="w-full py-3 px-6 bg-blue-500 text-white rounded-md hover:bg-blue-600 text-xl font-semibold shadow-md transition duration-300 ease-in-out">
Upload
</button>
</div>
</form>
</div>
</div>
{{ template "footer.html" . }}

View File

@ -0,0 +1,6 @@
</div>
</body>
</html>

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$ wrappd.sh</title>
<script src="/static/tailwind.js"></script>
<script src="/static/echarts.min.js"></script>
<link href="/static/animate.min.css" rel="stylesheet">
</head>
<body class="bg-gray-900 text-white font-sans antialiased">
<!-- Logo -->
<div class="flex justify-center">
<a href="/">
<img src="/static/logo2.png" alt="Wrappd Logo" class="w-64 h-64">
</a>
</div>
<!-- Contenedor principal centrado -->
<div class="mx-auto p-6 max-w-4xl">

View File

@ -0,0 +1,74 @@
{{ template "header.html" . }}
<h1 class="text-xl text-center text-blue-400 cursor-pointer hover:text-blue-600 mb-8">
<a href="{{.baseURL}}/{{.uniqueID}}" id="share-url">{{.baseURL}}/{{.uniqueID}}</a>
</h1>
<h2 class="text-4xl sm:text-3xl font-semibold text-center text-gray-100 mb-6">
Command Statistics
</h1>
<div class="chart-container">
<h2 class="text-2xl font-bold text-gray-200 mb-4">Top Commands Chart</h2>
<div class="chart">
{{.chartHTML | safeHTML}}
</div>
</div>
<div class="container" style="align-items: flex-start; justify-content:
flex-start">
<section class="mb-8 flex-1">
<h2 class="text-2xl font-bold text-gray-200 mb-4">Top Commands</h2>
<ul class="list-disc pl-6 space-y-2">
{{range .topCommands}}
<li class="text-lg text-gray-400">{{.Command}} - <span class="font-semibold text-blue-400">{{.Count}} times</span></li>
{{end}}
</ul>
</section>
<section class="mb-8 flex-1">
<h2 class="text-2xl font-bold text-gray-200 mb-4">Tools</h2>
<ul class="list-disc pl-6 space-y-2">
{{range .categoryStats}}
<li class="text-lg text-gray-400">{{.Category}} - <span class="font-semibold text-blue-400">{{.Count}} times</span></li>
{{end}}
</ul>
</section>
<section class="mb-8 flex-1">
<h3 class="text-xl font-semibold">Common Patterns</h3>
<ul>
{{range .commonPatternStats}}
<li class="text-lg text-gray-400">{{.Command}} - <span class="font-semibold text-blue-400">{{.Count}} times</span></li>
{{end}}
</ul>
<h3 class="text-xl font-semibold">Pipe Counts</h3>
<ul>
<li class="text-lg text-gray-400"><span
class="font-semibold
text-blue-400">{{.pipeRedirectionCounts.pipe}} times</span></li>
</ul>
</section>
</div>
<div class="mt-8 flex justify-center space-x-4">
<a
href="https://twitter.com/intent/tweet?text=Check%20out%20my%20command%20stats!&url={{.baseURL}}/{{.uniqueID}}"
target="_blank"
class="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition duration-200">Share on Twitter</a>
<a
href="https://mastodon.social/share?text=Check%20out%20my%20command%20stats!%20{{.baseURL}}/{{.uniqueID}}"
target="_blank"
class="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition duration-200">Share on Mastodon</a>
<a
href="https://bsky.app/share?text=Check%20out%20my%20command%20stats!%20{{.baseURL}}/{{.uniqueID}}"
target="_blank"
class="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition duration-200">Share on Bluesky</a>
<a
href="https://www.linkedin.com/sharing/share-offsite/?url={{.baseURL}}/{{.uniqueID}}" target="_blank" class="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition duration-200">Share on LinkedIn</a>
</div>
{{ template "footer.html" . }}

156
internal/web/utils.go Normal file
View File

@ -0,0 +1,156 @@
package web
import (
"bufio"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"github.com/go-echarts/go-echarts/v2/charts"
"github.com/go-echarts/go-echarts/v2/opts"
)
func loadTemplates(name string) (*template.Template, error) {
funcs := template.FuncMap{
"safeHTML": func(s string) interface{} { return template.HTML(s) },
}
templates, err := loadTemplatesFromDir("internal/web/templates")
if err != nil {
log.Printf("Error loading templates: %v", err)
return nil, err
}
return template.New(name).Funcs(funcs).ParseFiles(templates...)
}
func ConvertAndSort[K comparable](data map[K]int, limit int) []CommandStat {
var result []CommandStat
for key, count := range data {
result = append(result, CommandStat{
Command: fmt.Sprintf("%v", key),
Count: count,
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Count > result[j].Count
})
if len(result) > limit {
result = result[:limit]
}
return result
}
func parseHistoryFile(file http.File) (map[string]int, error) {
commandCounts := make(map[string]int)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
command := strings.Fields(line)[0]
commandCounts[command]++
}
if err := scanner.Err(); err != nil {
return nil, err
}
return commandCounts, nil
}
func generateTopCommands(commandCounts map[string]int, topN int) []CommandStat {
stats := make([]CommandStat, 0, len(commandCounts))
for cmd, count := range commandCounts {
stats = append(stats, CommandStat{Command: cmd, Count: count})
}
sort.Slice(stats, func(i, j int) bool {
return stats[i].Count > stats[j].Count
})
if topN < len(stats) {
stats = stats[:topN]
}
return stats
}
func createBarChart(stats []CommandStat) *charts.Bar {
bar := charts.NewBar()
bar.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
Title: "Top Commands",
Subtitle: "Comandos más usados",
}),
)
commands := make([]string, len(stats))
counts := make([]int, len(stats))
for i, stat := range stats {
commands[i] = stat.Command
counts[i] = stat.Count
}
bar.SetXAxis(commands).
AddSeries("Frecuencia", generateBarItems(counts))
return bar
}
func generateBarItems(data []int) []opts.BarData {
items := make([]opts.BarData, len(data))
for i, v := range data {
items[i] = opts.BarData{Value: v}
}
return items
}
func saveResults(uniqueID string, stats map[string]interface{}) {
filename := "results/" + uniqueID + ".json"
file, err := os.Create(filename)
if err != nil {
log.Fatal(err)
}
defer file.Close()
json.NewEncoder(file).Encode(stats)
}
func loadResults(uniqueID string) (map[string]interface{}, error) {
filename := "results/" + uniqueID + ".json"
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
var stats map[string]interface{}
err = json.NewDecoder(file).Decode(&stats)
return stats, err
}
func loadTemplatesFromDir(dir string) ([]string, error) {
var templates []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && filepath.Ext(path) == ".html" {
templates = append(templates, path)
}
return nil
})
return templates, err
}

0
results/delete.me Normal file
View File

0
uploads/delete.me Normal file
View File