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