Compare commits
22 Commits
main
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
334c9d4c95 | ||
|
|
cf46a2ae45 | ||
|
|
74a36bc9da | ||
|
|
406a116786 | ||
|
|
4c826193ee | ||
|
|
e4b6074303 | ||
|
|
df0bdb9918 | ||
|
|
ce4c815ef5 | ||
|
|
8945341250 | ||
|
|
7feec35c18 | ||
|
|
e3124527ae | ||
|
|
322fde4ead | ||
|
|
167b9f48fe | ||
|
|
4acf12b0da | ||
|
|
84a59895a7 | ||
|
|
053f126e19 | ||
|
|
5c6afc9b69 | ||
|
|
1ed655ff32 | ||
|
|
dad7e07486 | ||
|
|
6e9016b5de | ||
|
|
9aa21f672e | ||
|
|
5c4ecc841c |
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# Make a copy of this file and name it .env, then fill in the values below.
|
||||
DEV=True
|
||||
DB_URI=
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ package.json
|
||||
|
||||
*.db
|
||||
*.ext
|
||||
.env
|
||||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
|
||||
@@ -2,20 +2,31 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/cmd/pm/tasks"
|
||||
"github.com/LazyBachelor/LazyPM/internal/app"
|
||||
"github.com/LazyBachelor/LazyPM/internal/commands/issues"
|
||||
"github.com/LazyBachelor/LazyPM/internal/commands/survey"
|
||||
"github.com/LazyBachelor/LazyPM/internal/models"
|
||||
"github.com/LazyBachelor/LazyPM/pkg/repl"
|
||||
"github.com/LazyBachelor/LazyPM/pkg/task"
|
||||
"github.com/LazyBachelor/LazyPM/pkg/tui"
|
||||
"github.com/LazyBachelor/LazyPM/pkg/web"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
err := godotenv.Load(".env")
|
||||
|
||||
if err != nil {
|
||||
log.Println("Error loading .env file")
|
||||
}
|
||||
|
||||
models.BaseConfig = models.BaseConfig.LoadFromEnv()
|
||||
|
||||
task.RegisterInterface("tui", tui.New())
|
||||
task.RegisterInterface("web", web.New())
|
||||
task.RegisterInterface("repl", repl.New())
|
||||
@@ -169,6 +180,8 @@ func commandNeedsApp(cmd *cobra.Command) bool {
|
||||
|
||||
func ensureAppInitialized(ctx context.Context) error {
|
||||
if App != nil {
|
||||
survey.SetApp(App)
|
||||
issues.SetApp(App)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/cmd/pm/tasks"
|
||||
"github.com/LazyBachelor/LazyPM/internal/commands/survey"
|
||||
"github.com/LazyBachelor/LazyPM/internal/storage"
|
||||
"github.com/LazyBachelor/LazyPM/pkg/task"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -28,6 +31,79 @@ func runStartCmd(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("application services are not available")
|
||||
}
|
||||
|
||||
if !cmd.Flags().Changed("dev") {
|
||||
var mongoStorage *storage.MongoStorage
|
||||
var continueWithoutSubmitting bool
|
||||
|
||||
for {
|
||||
|
||||
if app.Config.DbUri == "" {
|
||||
cmd.Println("No database URI provided in environment, survey responses will not be submitted.")
|
||||
break
|
||||
}
|
||||
|
||||
db, err := storage.NewMongoStorageInteractive(cmd.Context(), app.Config.DbUri)
|
||||
if err == nil {
|
||||
mongoStorage = db
|
||||
break
|
||||
}
|
||||
|
||||
cmd.Println("Failed to connect to database, survey responses will not be submitted.")
|
||||
|
||||
if err := huh.NewConfirm().
|
||||
Title("Do you want to continue without submitting your responses?").
|
||||
Description("You can fix your database connection and submit your responses later with the submit command.").
|
||||
Value(&continueWithoutSubmitting).
|
||||
WithTheme(huh.ThemeBase16()).
|
||||
RunAccessible(cmd.OutOrStdout(), cmd.InOrStdin()); err != nil {
|
||||
return fmt.Errorf("failed to read user input: %w", err)
|
||||
}
|
||||
|
||||
if continueWithoutSubmitting {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if mongoStorage != nil {
|
||||
cmd.Println("Connected to Database Successfully. Starting survey...")
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
defer mongoStorage.Close()
|
||||
|
||||
ctx := cmd.Context()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := mongoStorage.SubmitSurveyResponsesCmd(ctx, app.Config.AppDir); err != nil {
|
||||
cmd.Printf("Failed to submit survey responses: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := mongoStorage.SubmitSurveyResponsesCmd(ctx, app.Config.AppDir); err != nil {
|
||||
cmd.Printf("Failed to submit survey responses: %v\n", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := mongoStorage.SubmitSurveyResponsesCmd(context.Background(), app.Config.AppDir); err != nil {
|
||||
cmd.Printf("Failed to submit survey responses on shutdown: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
} else {
|
||||
cmd.Println("Starting survey without database connection. Your responses will not be submitted...")
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
interfaces := initInterfaces()
|
||||
surveyTasks := initTasks(app)
|
||||
|
||||
@@ -49,11 +125,11 @@ func runStartCmd(cmd *cobra.Command, args []string) error {
|
||||
survey.Task: surveyTasks[survey.Task],
|
||||
}
|
||||
}
|
||||
|
||||
if err := newIntroModel().Run(); err != nil {
|
||||
return returnIfUserQuit(err, "failed to run intro")
|
||||
if !cmd.Flags().Changed("dev") {
|
||||
if err := newIntroModel().Run(); err != nil {
|
||||
return returnIfUserQuit(err, "failed to run intro")
|
||||
}
|
||||
}
|
||||
|
||||
if err := taskLoop(cmd.Context(), app, surveyTasks, interfaces); err != nil {
|
||||
return returnIfUserQuit(err, "task loop failed")
|
||||
}
|
||||
|
||||
1
go.mod
1
go.mod
@@ -5,6 +5,7 @@ go 1.25.6
|
||||
require (
|
||||
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260222090600-424e9964d3a3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/steveyegge/beads v0.49.6
|
||||
go.mongodb.org/mongo-driver v1.17.9
|
||||
|
||||
2
go.sum
2
go.sum
@@ -132,6 +132,8 @@ github.com/haatos/goshipit v0.0.0-20260305043009-36e5c9a2e5c6 h1:Yakj3J1ZxYCpFtB
|
||||
github.com/haatos/goshipit v0.0.0-20260305043009-36e5c9a2e5c6/go.mod h1:2A31H3xgQHTgNp8OlX7aliXaY3QFwtI7XHuaP8G1ZSw=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kevinburke/ssh_config v1.5.0 h1:3cPZmE54xb5j3G5xQCjSvokqNwU2uW+3ry1+PRLSPpA=
|
||||
github.com/kevinburke/ssh_config v1.5.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/LazyBachelor/LazyPM/internal/models"
|
||||
"github.com/LazyBachelor/LazyPM/internal/storage"
|
||||
"github.com/steveyegge/beads"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type App = models.App
|
||||
@@ -22,13 +23,13 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro
|
||||
}
|
||||
|
||||
if !config.AutoInit {
|
||||
if err := b.initializer.Init(config.BeadsDBPath); err != nil {
|
||||
if err := b.initializer.Init(config.AppDir + "db.db"); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if b.issueService == nil {
|
||||
sqliteStore, err := beads.NewSQLiteStorage(b.ctx, config.BeadsDBPath)
|
||||
sqliteStore, err := beads.NewSQLiteStorage(b.ctx, config.AppDir+"db.db")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -42,7 +43,7 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro
|
||||
|
||||
if b.statsService == nil {
|
||||
statStore := storage.NewJsonStorage(config.StatisticsStoragePath, &models.Statistics{
|
||||
ID: 0,
|
||||
ID: primitive.NewObjectID(),
|
||||
StartTime: time.Now(),
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/internal/models"
|
||||
"github.com/LazyBachelor/LazyPM/internal/storage"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type StatisticsService struct {
|
||||
@@ -42,6 +43,16 @@ func (s *StatisticsService) GetStatistics() (models.Statistics, error) {
|
||||
return *s.storage.Data, nil
|
||||
}
|
||||
|
||||
func (s *StatisticsService) GetParticipantID() primitive.ObjectID {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.storage.Data == nil {
|
||||
return primitive.NilObjectID
|
||||
}
|
||||
return s.storage.Data.ID
|
||||
}
|
||||
|
||||
func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRunMetrics) error {
|
||||
_ = ctx
|
||||
|
||||
@@ -63,8 +74,8 @@ func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRu
|
||||
}
|
||||
|
||||
stats.EndTime = now
|
||||
stats.Duration = stats.EndTime.Sub(stats.StartTime)
|
||||
stats.InterfaceType = run.InterfaceType
|
||||
stats.DurationMs = stats.EndTime.Sub(stats.StartTime).Milliseconds()
|
||||
stats.LastInterfaceType = run.InterfaceType
|
||||
|
||||
stats.TaskRuns++
|
||||
stats.LastTaskName = run.TaskName
|
||||
|
||||
@@ -26,11 +26,6 @@ Your responses will be kept confidential and used solely for research purposes.`
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "survey",
|
||||
Long: long,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
if app != nil {
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), appKey, app))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func SetApp(application *App) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
DevFlag bool
|
||||
InterfaceType string
|
||||
Task string
|
||||
)
|
||||
@@ -22,4 +23,5 @@ func init() {
|
||||
StartCmd.Flags().StringVarP(&InterfaceType, "interface", "i", "", "Specify interface.")
|
||||
StartCmd.RegisterFlagCompletionFunc("task", shellcomp.CompletionFunc(task.ListTasks()))
|
||||
StartCmd.RegisterFlagCompletionFunc("interface", shellcomp.CompletionFunc(task.ListInterfaces()))
|
||||
StartCmd.Flags().BoolVar(&DevFlag, "dev", false, "Enable development mode, which skips database connection, submission and intro")
|
||||
}
|
||||
|
||||
@@ -1,123 +1,39 @@
|
||||
package survey
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/internal/models"
|
||||
"github.com/LazyBachelor/LazyPM/internal/storage"
|
||||
"github.com/spf13/cobra"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
var mongoURI = os.Getenv("MONGODB_URI")
|
||||
|
||||
var SubmitCmd = &cobra.Command{
|
||||
Use: "submit",
|
||||
Short: "Submit your survey responses",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, error := mongo.Connect(cmd.Context(), options.Client().ApplyURI(mongoURI))
|
||||
if error != nil {
|
||||
return fmt.Errorf("Failed to connect to MongoDB: %v", error)
|
||||
app := AppFromContext(cmd.Context())
|
||||
|
||||
if app == nil {
|
||||
return fmt.Errorf("application context not initialized")
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := client.Disconnect(cmd.Context()); err != nil {
|
||||
fmt.Printf("Failed to disconnect MongoDB client: %v", err)
|
||||
}
|
||||
}()
|
||||
if app.Config.DbUri == "" {
|
||||
cmd.Println("No database URI provided in environment, survey responses will not be submitted.")
|
||||
return nil
|
||||
}
|
||||
|
||||
userStatscollection := client.Database("Responses").Collection("stats")
|
||||
taskMetricsCollection := client.Database("Responses").Collection("task_metrics")
|
||||
|
||||
pmDir := "./.pm/"
|
||||
|
||||
entries, err := os.ReadDir(pmDir)
|
||||
db, err := storage.NewMongoStorageInteractive(cmd.Context(), app.Config.DbUri)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to read .pm directory: %v", err)
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return fmt.Errorf("No files found in .pm directory")
|
||||
defer db.Close()
|
||||
|
||||
if err := db.SubmitSurveyResponsesCmd(cmd.Context(), app.Config.AppDir); err != nil {
|
||||
return fmt.Errorf("failed to submit survey responses: %w", err)
|
||||
}
|
||||
|
||||
statFile := pmDir + "stats.json"
|
||||
if _, err := os.Stat(statFile); os.IsNotExist(err) {
|
||||
return fmt.Errorf("stats.json not found in .pm directory")
|
||||
}
|
||||
|
||||
stats, err := getStats(statFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to read stats.json: %v", err)
|
||||
}
|
||||
|
||||
_, err = userStatscollection.InsertOne(cmd.Context(), stats)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to insert stats into database: %v", err)
|
||||
}
|
||||
|
||||
metricFiles := []string{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.Name() == "stats.json" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(entry.Name(), "-stats.json") {
|
||||
metricFiles = append(metricFiles, pmDir+entry.Name())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range metricFiles {
|
||||
metrics, err := getTaskMetrics(file)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to read metrics from %s: %v", file, err)
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = taskMetricsCollection.InsertOne(cmd.Context(), metrics)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to insert metrics from %s: %v", file, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Printf("Successfully submitted survey responses and metrics to the database")
|
||||
|
||||
cmd.Println("Successfully submitted survey responses and metrics to the database")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func getStats(file string) (*models.Statistics, error) {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var stats models.Statistics
|
||||
if err := json.Unmarshal(data, &stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func getTaskMetrics(file string) (*models.TaskMetricsFile, error) {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var metrics models.TaskMetricsFile
|
||||
if err := json.Unmarshal(data, &metrics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &metrics, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package models
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
@@ -57,5 +59,6 @@ type StatsService interface {
|
||||
Load(ctx context.Context) error
|
||||
Save(ctx context.Context) error
|
||||
GetStatistics() (Statistics, error)
|
||||
GetParticipantID() primitive.ObjectID
|
||||
RecordTaskRun(ctx context.Context, run TaskRunMetrics) error
|
||||
}
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
package models
|
||||
|
||||
import "os"
|
||||
|
||||
type Config struct {
|
||||
DbUri string
|
||||
AutoInit bool
|
||||
RootCmd string
|
||||
AppDir string
|
||||
WebAddress string
|
||||
BeadsDBPath string
|
||||
IssuePrefix string
|
||||
StatisticsStoragePath string
|
||||
ActionLogger func(string)
|
||||
StatisticsStoragePath string
|
||||
}
|
||||
|
||||
var BaseConfig = Config{
|
||||
DbUri: "",
|
||||
AutoInit: false,
|
||||
RootCmd: "pm",
|
||||
IssuePrefix: "pm",
|
||||
WebAddress: ":8080",
|
||||
BeadsDBPath: "./.pm/db.db",
|
||||
AppDir: "./.pm/",
|
||||
StatisticsStoragePath: "./.pm/stats.json",
|
||||
}
|
||||
|
||||
func (c Config) LoadFromEnv() Config {
|
||||
if dbURI, ok := os.LookupEnv("DB_URI"); ok {
|
||||
c.DbUri = dbURI
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Config) WithDbUri(uri string) Config {
|
||||
c.DbUri = uri
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Config) WithAutoInit(autoInit bool) Config {
|
||||
c.AutoInit = autoInit
|
||||
return c
|
||||
@@ -34,11 +50,6 @@ func (c Config) WithWebAddress(webAddress string) Config {
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Config) WithBeadsDBPath(beadsDBPath string) Config {
|
||||
c.BeadsDBPath = beadsDBPath
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Config) WithIssuePrefix(issuePrefix string) Config {
|
||||
c.IssuePrefix = issuePrefix
|
||||
return c
|
||||
|
||||
@@ -2,91 +2,95 @@ package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type Statistics struct {
|
||||
ID int `json:"id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
ID primitive.ObjectID `bson:"_id" json:"id"`
|
||||
StartTime time.Time `bson:"start_time" json:"start_time"`
|
||||
EndTime time.Time `bson:"end_time" json:"end_time"`
|
||||
DurationMs int64 `bson:"duration_ms" json:"duration_ms"`
|
||||
|
||||
InterfaceType InterfaceType `json:"interface_type"`
|
||||
TaskRuns int `json:"task_runs"`
|
||||
TasksCompleted int `json:"tasks_completed"`
|
||||
TasksFailed int `json:"tasks_failed"`
|
||||
LastInterfaceType InterfaceType `bson:"last_interface_type" json:"last_interface_type"`
|
||||
TaskRuns int `bson:"task_runs" json:"task_runs"`
|
||||
TasksCompleted int `bson:"tasks_completed" json:"tasks_completed"`
|
||||
TasksFailed int `bson:"tasks_failed" json:"tasks_failed"`
|
||||
|
||||
TotalDurationMs int64 `json:"total_duration_ms"`
|
||||
AverageDurationMs int64 `json:"average_duration_ms"`
|
||||
TotalDurationMs int64 `bson:"total_duration_ms" json:"total_duration_ms"`
|
||||
AverageDurationMs int64 `bson:"average_duration_ms" json:"average_duration_ms"`
|
||||
|
||||
TotalUserActions int `json:"total_user_actions"`
|
||||
QuestionnairesCompleted int `json:"questionnaires_completed"`
|
||||
QuestionnairesAbandoned int `json:"questionnaires_abandoned"`
|
||||
TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"`
|
||||
QuestionnairesCompleted int `bson:"questionnaires_completed" json:"questionnaires_completed"`
|
||||
QuestionnairesAbandoned int `bson:"questionnaires_abandoned" json:"questionnaires_abandoned"`
|
||||
|
||||
ValidationAttempts int `json:"validation_attempts"`
|
||||
ValidationSuccesses int `json:"validation_successes"`
|
||||
ValidationFailures int `json:"validation_failures"`
|
||||
ValidationChecksPassed int `json:"validation_checks_passed"`
|
||||
ValidationChecksFailed int `json:"validation_checks_failed"`
|
||||
ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"`
|
||||
ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"`
|
||||
ValidationFailures int `bson:"validation_failures" json:"validation_failures"`
|
||||
ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"`
|
||||
ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"`
|
||||
|
||||
LastTaskName string `json:"last_task_name"`
|
||||
LastRunID int `json:"last_run_id"`
|
||||
LastTaskName string `bson:"last_task_name" json:"last_task_name"`
|
||||
LastRunID int `bson:"last_run_id" json:"last_run_id"`
|
||||
}
|
||||
|
||||
type TaskMetricsFile struct {
|
||||
TaskName string `json:"task_name"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Summary TaskStatsSummary `json:"summary"`
|
||||
Runs []TaskRunMetrics `json:"runs"`
|
||||
ID primitive.ObjectID `bson:"_id" json:"id"`
|
||||
ParticipantID primitive.ObjectID `bson:"participant_id" json:"participant_id"`
|
||||
TaskName string `bson:"task_name" json:"task_name"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
Summary TaskStatsSummary `bson:"summary" json:"summary"`
|
||||
Runs []TaskRunMetrics `bson:"runs" json:"runs"`
|
||||
}
|
||||
|
||||
type TaskStatsSummary struct {
|
||||
TotalRuns int `json:"total_runs"`
|
||||
CompletedRuns int `json:"completed_runs"`
|
||||
IncompleteRuns int `json:"incomplete_runs"`
|
||||
TotalDurationMs int64 `json:"total_duration_ms"`
|
||||
AverageDurationMs int64 `json:"average_duration_ms"`
|
||||
TotalUserActions int `json:"total_user_actions"`
|
||||
QuestionnairesCompleted int `json:"questionnaires_completed"`
|
||||
QuestionnairesAbandoned int `json:"questionnaires_abandoned"`
|
||||
ValidationAttempts int `json:"validation_attempts"`
|
||||
ValidationSuccesses int `json:"validation_successes"`
|
||||
ValidationFailures int `json:"validation_failures"`
|
||||
ValidationChecksPassed int `json:"validation_checks_passed"`
|
||||
ValidationChecksFailed int `json:"validation_checks_failed"`
|
||||
LastInterfaceType InterfaceType `json:"last_interface_type"`
|
||||
FirstRunStartedAt time.Time `json:"first_run_started_at"`
|
||||
LastRunStartedAt time.Time `json:"last_run_started_at"`
|
||||
LastRunEndedAt time.Time `json:"last_run_ended_at"`
|
||||
TotalRuns int `bson:"total_runs" json:"total_runs"`
|
||||
CompletedRuns int `bson:"completed_runs" json:"completed_runs"`
|
||||
IncompleteRuns int `bson:"incomplete_runs" json:"incomplete_runs"`
|
||||
TotalDurationMs int64 `bson:"total_duration_ms" json:"total_duration_ms"`
|
||||
AverageDurationMs int64 `bson:"average_duration_ms" json:"average_duration_ms"`
|
||||
TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"`
|
||||
QuestionnairesCompleted int `bson:"questionnaires_completed" json:"questionnaires_completed"`
|
||||
QuestionnairesAbandoned int `bson:"questionnaires_abandoned" json:"questionnaires_abandoned"`
|
||||
ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"`
|
||||
ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"`
|
||||
ValidationFailures int `bson:"validation_failures" json:"validation_failures"`
|
||||
ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"`
|
||||
ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"`
|
||||
LastInterfaceType InterfaceType `bson:"last_interface_type" json:"last_interface_type"`
|
||||
FirstRunStartedAt time.Time `bson:"first_run_started_at" json:"first_run_started_at"`
|
||||
LastRunStartedAt time.Time `bson:"last_run_started_at" json:"last_run_started_at"`
|
||||
LastRunEndedAt time.Time `bson:"last_run_ended_at" json:"last_run_ended_at"`
|
||||
}
|
||||
|
||||
type TaskRunMetrics struct {
|
||||
RunID int `json:"run_id"`
|
||||
TaskName string `json:"task_name"`
|
||||
InterfaceType InterfaceType `json:"interface_type"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt time.Time `json:"ended_at"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Completed bool `json:"completed"`
|
||||
ValidationAttempts int `json:"validation_attempts"`
|
||||
ValidationSuccesses int `json:"validation_successes"`
|
||||
ValidationFailures int `json:"validation_failures"`
|
||||
ValidationChecksPassed int `json:"validation_checks_passed"`
|
||||
ValidationChecksFailed int `json:"validation_checks_failed"`
|
||||
LastValidationMessage string `json:"last_validation_message,omitempty"`
|
||||
QuestionnaireCompleted bool `json:"questionnaire_completed"`
|
||||
QuestionnaireUserQuit bool `json:"questionnaire_user_quit"`
|
||||
QuestionnaireAnswers map[string]any `json:"questionnaire_answers,omitempty"`
|
||||
Logs []TaskLogEntry `json:"logs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RunID int `bson:"run_id" json:"run_id"`
|
||||
TaskName string `bson:"task_name" json:"task_name"`
|
||||
InterfaceType InterfaceType `bson:"interface_type" json:"interface_type"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
EndedAt time.Time `bson:"ended_at" json:"ended_at"`
|
||||
DurationMs int64 `bson:"duration_ms" json:"duration_ms"`
|
||||
Completed bool `bson:"completed" json:"completed"`
|
||||
ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"`
|
||||
ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"`
|
||||
ValidationFailures int `bson:"validation_failures" json:"validation_failures"`
|
||||
ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"`
|
||||
ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"`
|
||||
LastValidationMessage string `bson:"last_validation_message,omitempty" json:"last_validation_message,omitempty"`
|
||||
QuestionnaireCompleted bool `bson:"questionnaire_completed" json:"questionnaire_completed"`
|
||||
QuestionnaireUserQuit bool `bson:"questionnaire_user_quit" json:"questionnaire_user_quit"`
|
||||
QuestionnaireAnswers map[string]any `bson:"questionnaire_answers,omitempty" json:"questionnaire_answers,omitempty"`
|
||||
Logs []TaskLogEntry `bson:"logs" json:"logs"`
|
||||
Error string `bson:"error,omitempty" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type TaskLogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
Timestamp time.Time `bson:"timestamp" json:"timestamp"`
|
||||
Level string `bson:"level" json:"level"`
|
||||
Message string `bson:"message" json:"message"`
|
||||
Source string `bson:"source,omitempty" json:"source,omitempty"`
|
||||
Action string `bson:"action,omitempty" json:"action,omitempty"`
|
||||
Target string `bson:"target,omitempty" json:"target,omitempty"`
|
||||
Result string `bson:"result,omitempty" json:"result,omitempty"`
|
||||
Attempt int `bson:"attempt,omitempty" json:"attempt,omitempty"`
|
||||
}
|
||||
|
||||
180
internal/storage/mongo.go
Normal file
180
internal/storage/mongo.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/internal/models"
|
||||
"github.com/charmbracelet/huh"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
type MongoStorage struct {
|
||||
client *mongo.Client
|
||||
}
|
||||
|
||||
func NewMongoStorage(ctx context.Context, uri, username, password string) (*MongoStorage, error) {
|
||||
credentials := options.Credential{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
client, err := mongo.Connect(ctx,
|
||||
options.Client().ApplyURI(uri).SetAuth(credentials))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to MongoDB: %v", err)
|
||||
}
|
||||
|
||||
if err := client.Ping(ctx, nil); err != nil {
|
||||
return nil, fmt.Errorf("cannot reach MongoDB: %v", err)
|
||||
}
|
||||
|
||||
return &MongoStorage{client: client}, nil
|
||||
}
|
||||
|
||||
func NewMongoStorageInteractive(ctx context.Context, uri string) (*MongoStorage, error) {
|
||||
var username, password string
|
||||
if os.Getenv("DB_USER") == "" {
|
||||
if err := huh.NewInput().
|
||||
Title("Enter the Database Username").
|
||||
Value(&username).
|
||||
WithTheme(huh.ThemeBase16()).Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read username: %w", err)
|
||||
}
|
||||
} else {
|
||||
username = os.Getenv("DB_USER")
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return nil, fmt.Errorf("No username provided.")
|
||||
}
|
||||
|
||||
if os.Getenv("DB_PASSWORD") == "" {
|
||||
if err := huh.NewInput().
|
||||
Title("Enter the Survey Password").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&password).
|
||||
WithTheme(huh.ThemeBase16()).Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
} else {
|
||||
password = os.Getenv("DB_PASSWORD")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("No password provided.")
|
||||
|
||||
}
|
||||
|
||||
mongoClient, err := NewMongoStorage(ctx, uri, username, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
return mongoClient, nil
|
||||
}
|
||||
|
||||
func (s *MongoStorage) Close() error {
|
||||
return s.client.Disconnect(context.Background())
|
||||
}
|
||||
|
||||
func (s *MongoStorage) SubmitSurveyResponsesCmd(ctx context.Context, dir string) error {
|
||||
pmDir := dir
|
||||
userStatscollection := s.client.Database("Responses").Collection("stats")
|
||||
taskMetricsCollection := s.client.Database("Responses").Collection("metrics")
|
||||
|
||||
entries, err := os.ReadDir(pmDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to read .pm directory: %v", err)
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return fmt.Errorf("No files found in .pm directory")
|
||||
}
|
||||
|
||||
statFile := pmDir + "stats.json"
|
||||
if _, err := os.Stat(statFile); os.IsNotExist(err) {
|
||||
return fmt.Errorf("stats.json not found in .pm directory")
|
||||
}
|
||||
|
||||
stats, err := getStats(statFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to read stats.json: %v", err)
|
||||
}
|
||||
|
||||
_, err = userStatscollection.UpdateOne(ctx,
|
||||
bson.M{"_id": stats.ID},
|
||||
bson.M{"$set": stats},
|
||||
options.Update().SetUpsert(true))
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to insert stats into database: %v", err)
|
||||
}
|
||||
|
||||
metricFiles := []string{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.Name() == "stats.json" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(entry.Name(), "-stats.json") {
|
||||
metricFiles = append(metricFiles, pmDir+entry.Name())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range metricFiles {
|
||||
metrics, err := getTaskMetrics(file)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to read metrics from %s: %v", file, err)
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = taskMetricsCollection.UpdateOne(ctx, bson.M{"_id": metrics.ID}, bson.M{"$set": metrics},
|
||||
options.Update().SetUpsert(true))
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("failed to insert metrics from %s: %v", file, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getStats(file string) (*models.Statistics, error) {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var stats models.Statistics
|
||||
if err := json.Unmarshal(data, &stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func getTaskMetrics(file string) (*models.TaskMetricsFile, error) {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var metrics models.TaskMetricsFile
|
||||
if err := json.Unmarshal(data, &metrics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &metrics, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/internal/models"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type RunLifecycle struct {
|
||||
@@ -23,9 +24,11 @@ func NewRunLifecycle(app *App, config Config, details models.TaskDetails, iType
|
||||
collector.recordUserAction(action)
|
||||
})
|
||||
|
||||
var participantID primitive.ObjectID
|
||||
var store MetricsStore
|
||||
if config.StatisticsStoragePath != "" {
|
||||
store = NewFileMetricsStore(config.StatisticsStoragePath, logger)
|
||||
participantID = app.Stats.GetParticipantID()
|
||||
store = NewFileMetricsStore(config.StatisticsStoragePath, participantID, logger)
|
||||
}
|
||||
|
||||
return &RunLifecycle{
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/internal/models"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type MetricsStore interface {
|
||||
@@ -17,14 +18,16 @@ type MetricsStore interface {
|
||||
}
|
||||
|
||||
type FileMetricsStore struct {
|
||||
path string
|
||||
logger *slog.Logger
|
||||
path string
|
||||
participantID primitive.ObjectID
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewFileMetricsStore(path string, logger *slog.Logger) *FileMetricsStore {
|
||||
func NewFileMetricsStore(path string, participantID primitive.ObjectID, logger *slog.Logger) *FileMetricsStore {
|
||||
return &FileMetricsStore{
|
||||
path: path,
|
||||
logger: logger,
|
||||
path: path,
|
||||
participantID: participantID,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +44,10 @@ func (s *FileMetricsStore) Append(ctx context.Context, taskName string, run mode
|
||||
}
|
||||
|
||||
metrics := models.TaskMetricsFile{
|
||||
TaskName: taskName,
|
||||
Runs: []models.TaskRunMetrics{},
|
||||
ID: primitive.NewObjectID(),
|
||||
ParticipantID: s.participantID,
|
||||
TaskName: taskName,
|
||||
Runs: []models.TaskRunMetrics{},
|
||||
}
|
||||
|
||||
if err := readMetrics(&metrics, s.path); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user