Skip to content

Commit

Permalink
Merge pull request #313 from nerdalert/patternfly-ui
Browse files Browse the repository at this point in the history
Add a PatternFly UI for jobs
  • Loading branch information
mergify[bot] authored Apr 29, 2024
2 parents 753273d + 4936b20 commit 62bf0e0
Show file tree
Hide file tree
Showing 48 changed files with 22,329 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ models
generated
.idea
.DS_Store

# UI assets
**/node_modules
dist
yarn-error.log
yarn.lock
stats.json
6 changes: 6 additions & 0 deletions gobot/handlers/issue_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const (
RedisKeyErrors = "errors"
RedisKeyRequestTime = "request_time"
RedisKeyDuration = "duration"
RedisKeyStatus = "status"
)

type PRCommentHandler struct {
Expand Down Expand Up @@ -196,6 +197,11 @@ func (h *PRCommentHandler) queueGenerateJob(ctx context.Context, client *github.
return err
}

err = setJobKey(r, jobNumber, RedisKeyStatus, util.CheckStatusPending)
if err != nil {
return err
}

err = setJobKey(r, jobNumber, RedisKeyRequestTime, strconv.FormatInt(time.Now().Unix(), 10))
if err != nil {
return err
Expand Down
17 changes: 17 additions & 0 deletions ui/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Editor configuration, see http://editorconfig.org
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.snap]
max_line_length = off
trim_trailing_whitespace = false

[*.md]
max_line_length = off
trim_trailing_whitespace = false
76 changes: 76 additions & 0 deletions ui/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
module.exports = {
// tells eslint to use the TypeScript parser
"parser": "@typescript-eslint/parser",
// tell the TypeScript parser that we want to use JSX syntax
"parserOptions": {
"tsx": true,
"jsx": true,
"js": true,
"useJSXTextNode": true,
"project": "./tsconfig.json",
"tsconfigRootDir": "."
},
// we want to use the recommended rules provided from the typescript plugin
"extends": [
"@redhat-cloud-services/eslint-config-redhat-cloud-services",
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
],
"globals": {
"window": "readonly",
"describe": "readonly",
"test": "readonly",
"expect": "readonly",
"it": "readonly",
"process": "readonly",
"document": "readonly",
"insights": "readonly",
"shallow": "readonly",
"render": "readonly",
"mount": "readonly"
},
"overrides": [
{
"files": ["src/**/*.ts", "src/**/*.tsx"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["plugin:@typescript-eslint/recommended"],
"rules": {
"react/prop-types": "off",
"@typescript-eslint/no-unused-vars": "error"
},
},
],
"settings": {
"react": {
"version": "^16.11.0"
}
},
// includes the typescript specific rules found here: https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#supported-rules
"plugins": [
"@typescript-eslint",
"react-hooks",
"eslint-plugin-react-hooks"
],
"rules": {
"sort-imports": [
"error",
{
"ignoreDeclarationSort": true
}
],
"@typescript-eslint/explicit-function-return-type": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/interface-name-prefix": "off",
"prettier/prettier": "off",
"import/no-unresolved": "off",
"import/extensions": "off",
"react/prop-types": "off"
},
"env": {
"browser": true,
"node": true
}
}
1 change: 1 addition & 0 deletions ui/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package.json
4 changes: 4 additions & 0 deletions ui/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"printWidth": 120
}
48 changes: 48 additions & 0 deletions ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# InstructLab Bot UI

This is a [Patternfly](https://www.patternfly.org/get-started/develop/) react deployment for front-ending InstructLab Bot jobs. The framework is based off [patternfloy-react-seed](https://github.com/patternfly/patternfly-react-seed) but upgraded to use the latest React v6+. The data is all read only streaming from redis, via the go-streamer service.

## Quickstart

- Start the bot [compose stack](../deploy/compose).
- Start go-streamer on the same host as the redis server since it will be connecting to `localhost:6379` by default, but can be set with `--redis-server`. The same applies to the listening websocket port `--listen-address` which defaults to `localhost:3000`.

```bash
cd ui/go-stream
./go-stream
```

- Start [webpack](https://github.com/webpack/webpack).

```bash
cd ui/
npm run start:dev
```

## Authentication

Currently, there is no OAuth implementation, this just supports a user/pass defined at runtime. If no `/ui/.env` file is defined, the user/pass is simply admin/password. To change those defaults, create the `/ui/.env` file and fill in the account user/pass with the following.

```text
REACT_APP_ADMIN_USERNAME=<user>
REACT_APP_ADMIN_PASSWORD=<pass>
```

## Development Scripts

```bash
# Install development/build dependencies
npm install

# Start the development server
npm run start:dev

# Run a production build (outputs to "dist" dir)
npm run build

# Start the express server (run a production build first)
npm run start

# Start storybook component explorer
npm run storybook
```
7 changes: 7 additions & 0 deletions ui/dr-surge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const fs = require('fs');
const path = require('path');
const indexPath = path.resolve(__dirname, 'dist/index.html');
const targetFilePath = path.resolve(__dirname, 'dist/200.html');
// ensure we have bookmarkable url's when publishing to surge
// https://surge.sh/help/adding-a-200-page-for-client-side-routing
fs.createReadStream(indexPath).pipe(fs.createWriteStream(targetFilePath));
177 changes: 177 additions & 0 deletions ui/go-stream/go-stream.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package main

import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/go-redis/redis/v8"
"github.com/gorilla/websocket"
"github.com/spf13/pflag"
"go.uber.org/zap"
)

var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}

var rdb *redis.Client
var ctx = context.Background()
var logger *zap.Logger

type JobData struct {
JobID string `json:"jobID"`
Duration string `json:"duration"`
Status string `json:"status"`
S3URL string `json:"s3URL"`
ModelName string `json:"modelName"`
RepoOwner string `json:"repoOwner"`
Author string `json:"author"`
PrNumber string `json:"prNumber"`
PrSHA string `json:"prSHA"`
RequestTime string `json:"requestTime"`
Errors string `json:"errors"`
RepoName string `json:"repoName"`
JobType string `json:"jobType"`
InstallationID string `json:"installationID"`
}

func handleConnections(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
logger.Error("Error upgrading WebSocket", zap.Error(err))
return
}
defer ws.Close()
logger.Debug("WebSocket connection successfully upgraded.")

// Immediately send all jobs in the results queue
sendAllJobs(ws)

failedPings := 0
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()

for {
select {
case <-ticker.C:
// Check if the WebSocket connection is closed
if ws == nil || ws.CloseHandler() != nil {
logger.Debug("WebSocket connection appears to be closed.")
return
}

if err := ws.WriteMessage(websocket.PingMessage, nil); err != nil {
failedPings++
logger.Info("Ping failed", zap.Int("attempt", failedPings), zap.Error(err))
if failedPings > 3 {
logger.Info("Closing connection after multiple failed pings.")
return
}
} else {
// Reset failed pings count on successful ping
failedPings = 0
}
}
}
}

func sendAllJobs(ws *websocket.Conn) {
jobIDs, err := rdb.LRange(ctx, "results", 0, -1).Result()
if err != nil {
logger.Error("Error retrieving job IDs from Redis", zap.Error(err))
return
}
logger.Debug("Sending data for jobs.", zap.Int("count", len(jobIDs)))

for _, jobID := range jobIDs {
jobData := fetchJobData(jobID)
jsonData, err := json.Marshal(jobData)
if err != nil {
logger.Error("Error marshalling job data to JSON", zap.Error(err))
continue
}
if err := ws.WriteMessage(websocket.TextMessage, jsonData); err != nil {
logger.Error("Error sending job data over WebSocket", zap.Error(err))
continue
}
logger.Debug("Job data sent.", zap.String("Job ID", jobID))
}
}

func fetchJobData(jobID string) JobData {
var jobData JobData
jobData.JobID = jobID
jobData.Duration = rdb.Get(ctx, fmt.Sprintf("jobs:%s:duration", jobID)).Val()
jobData.Status = rdb.Get(ctx, fmt.Sprintf("jobs:%s:status", jobID)).Val()
jobData.S3URL = rdb.Get(ctx, fmt.Sprintf("jobs:%s:s3_url", jobID)).Val()
jobData.ModelName = rdb.Get(ctx, fmt.Sprintf("jobs:%s:model_name", jobID)).Val()
jobData.RepoOwner = rdb.Get(ctx, fmt.Sprintf("jobs:%s:repo_owner", jobID)).Val()
jobData.Author = rdb.Get(ctx, fmt.Sprintf("jobs:%s:author", jobID)).Val()
jobData.PrNumber = rdb.Get(ctx, fmt.Sprintf("jobs:%s:pr_number", jobID)).Val()
jobData.PrSHA = rdb.Get(ctx, fmt.Sprintf("jobs:%s:pr_sha", jobID)).Val()
jobData.RequestTime = rdb.Get(ctx, fmt.Sprintf("jobs:%s:request_time", jobID)).Val()
jobData.Errors = rdb.Get(ctx, fmt.Sprintf("jobs:%s:errors", jobID)).Val()
jobData.RepoName = rdb.Get(ctx, fmt.Sprintf("jobs:%s:repo_name", jobID)).Val()
jobData.JobType = rdb.Get(ctx, fmt.Sprintf("jobs:%s:job_type", jobID)).Val()
jobData.InstallationID = rdb.Get(ctx, fmt.Sprintf("jobs:%s:installation_id", jobID)).Val()

logger.Debug("Fetched data for job.", zap.String("Job ID", jobID), zap.Any("Data", jobData))
return jobData
}

func setupRoutes() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Redis Queue Dashboard")
})
http.HandleFunc("/ws", handleConnections)
}

func setupLogger(debugMode bool) *zap.Logger {
var logLevel zap.AtomicLevel
if debugMode {
logLevel = zap.NewAtomicLevelAt(zap.DebugLevel)
} else {
logLevel = zap.NewAtomicLevelAt(zap.InfoLevel)
}

loggerConfig := zap.Config{
Level: logLevel,
Encoding: "console",
EncoderConfig: zap.NewDevelopmentEncoderConfig(),
OutputPaths: []string{"stderr"},
ErrorOutputPaths: []string{"stderr"},
}
logger, err := loggerConfig.Build()
if err != nil {
panic(fmt.Sprintf("Cannot build logger: %v", err))
}
return logger
}

func main() {
debugFlag := pflag.Bool("debug", false, "Enable debug mode")
listenAddress := pflag.String("listen-address", "localhost:3000", "Address to listen on")
redisAddress := pflag.String("redis-server", "localhost:6379", "Redis server address")
pflag.Parse()

logger = setupLogger(*debugFlag)
defer logger.Sync()
zap.ReplaceGlobals(logger)

rdb = redis.NewClient(&redis.Options{
Addr: *redisAddress,
})

setupRoutes()

logger.Info("Server starting", zap.String("listen-address", *listenAddress))
err := http.ListenAndServe(*listenAddress, nil)
if err != nil {
logger.Error("ListenAndServe failed", zap.Error(err))
}
}
17 changes: 17 additions & 0 deletions ui/go-stream/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module github.com/instructlab/instructlab-bot/ui/go-stream

go 1.22.1

require (
github.com/go-redis/redis/v8 v8.11.5
github.com/gorilla/websocket v1.5.1
github.com/spf13/pflag v1.0.5
go.uber.org/zap v1.27.0
)

require (
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/net v0.17.0 // indirect
)
Loading

0 comments on commit 62bf0e0

Please sign in to comment.