Initial commit
This commit is contained in:
commit
b681bf6d1c
8
.air.toml
Normal file
8
.air.toml
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.env
|
||||
tmp
|
||||
results/*.json
|
||||
|
28
Dockerfile
Normal file
28
Dockerfile
Normal 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
220
Makefile
Normal 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
70
README.md
Normal 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
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
43173
assets/echarts.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
assets/logo.jpg
Normal file
BIN
assets/logo.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 74 KiB |
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 154 KiB |
BIN
assets/logo2.png
Normal file
BIN
assets/logo2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 137 KiB |
15061
assets/tailwind.js
Normal file
15061
assets/tailwind.js
Normal file
File diff suppressed because it is too large
Load Diff
33
cmd/web/main.go
Normal file
33
cmd/web/main.go
Normal 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
19
docker-compose.yml
Normal 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
5
env-sample
Normal 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
9
go.mod
Normal 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
14
go.sum
Normal 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
130
internal/web/controller.go
Normal 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
70
internal/web/model.go
Normal 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
20
internal/web/routes.go
Normal 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)
|
||||
}
|
||||
}
|
19
internal/web/templates/index.html
Normal file
19
internal/web/templates/index.html
Normal 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" . }}
|
6
internal/web/templates/partials/footer.html
Normal file
6
internal/web/templates/partials/footer.html
Normal file
@ -0,0 +1,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
22
internal/web/templates/partials/header.html
Normal file
22
internal/web/templates/partials/header.html
Normal 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">
|
74
internal/web/templates/result.html
Normal file
74
internal/web/templates/result.html
Normal 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
156
internal/web/utils.go
Normal 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
0
results/delete.me
Normal file
0
uploads/delete.me
Normal file
0
uploads/delete.me
Normal file
Loading…
Reference in New Issue
Block a user