Merge remote-tracking branch 'gitea/main'
commit
c89a9a8ada
38
ai.go
38
ai.go
|
|
@ -4,15 +4,49 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
nn "github.com/nikolaydubina/llama2.go/exp/nnfast"
|
nn "github.com/nikolaydubina/llama2.go/exp/nnfast"
|
||||||
"github.com/nikolaydubina/llama2.go/llama2"
|
"github.com/nikolaydubina/llama2.go/llama2"
|
||||||
|
"github.com/tmc/langchaingo/llms"
|
||||||
|
"github.com/tmc/langchaingo/llms/ollama"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AI interface {
|
type AI interface {
|
||||||
Do(context.Context, string) (string, error)
|
Do(context.Context, string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AINoop struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAINoop() AINoop {
|
||||||
|
return AINoop{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ai AINoop) Do(ctx context.Context, prompt string) (string, error) {
|
||||||
|
return ":shrug:", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIOllama struct {
|
||||||
|
model string
|
||||||
|
url string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAIOllama(url, model string) AIOllama {
|
||||||
|
return AIOllama{url: url, model: model}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ai AIOllama) Do(ctx context.Context, prompt string) (string, error) {
|
||||||
|
llm, err := ollama.New(
|
||||||
|
ollama.WithModel(ai.model),
|
||||||
|
ollama.WithServerURL(ai.url),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return llms.GenerateFromSinglePrompt(ctx, llm, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
type AILocal struct {
|
type AILocal struct {
|
||||||
checkpointPath string
|
checkpointPath string
|
||||||
tokenizerPath string
|
tokenizerPath string
|
||||||
|
|
@ -73,7 +107,7 @@ func (ai AILocal) Do(ctx context.Context, prompt string) (string, error) {
|
||||||
|
|
||||||
runState := llama2.NewRunState(config)
|
runState := llama2.NewRunState(config)
|
||||||
|
|
||||||
promptTokens := vocab.Encode(prompt)
|
promptTokens := vocab.Encode(strings.ReplaceAll(prompt, "\n", "<0x0A>"))
|
||||||
|
|
||||||
out := bytes.NewBuffer(nil)
|
out := bytes.NewBuffer(nil)
|
||||||
|
|
||||||
|
|
@ -131,5 +165,5 @@ func (ai AILocal) Do(ctx context.Context, prompt string) (string, error) {
|
||||||
}
|
}
|
||||||
out.Write([]byte("\n"))
|
out.Write([]byte("\n"))
|
||||||
|
|
||||||
return string(out.Bytes()), nil
|
return strings.ReplaceAll(string(out.Bytes()), "<0x0A>", "\n"), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
ai_test.go
45
ai_test.go
|
|
@ -13,18 +13,30 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAILocal(t *testing.T) {
|
func TestAINoop(t *testing.T) {
|
||||||
ctx, can := context.WithTimeout(context.Background(), time.Minute)
|
ai := NewAINoop()
|
||||||
defer can()
|
|
||||||
|
|
||||||
|
testAI(t, ai)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIOllama(t *testing.T) {
|
||||||
|
ai := NewAIOllama("http://localhost:11434", "gemma:2b")
|
||||||
|
|
||||||
|
testAI(t, ai)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAILocal(t *testing.T) {
|
||||||
d := os.TempDir()
|
d := os.TempDir()
|
||||||
for k, u := range map[string]string{
|
checkpoints := "checkpoints"
|
||||||
"checkpoints": "https://huggingface.co/karpathy/tinyllamas/resolve/main/stories110M.bin",
|
tokenizer := "tokenizer"
|
||||||
"tokenizer": "https://github.com/karpathy/llama2.c/raw/master/tokenizer.bin",
|
for u, p := range map[string]*string{
|
||||||
|
"https://huggingface.co/karpathy/tinyllamas/resolve/main/stories110M.bin": &checkpoints,
|
||||||
|
"https://github.com/karpathy/llama2.c/raw/master/tokenizer.bin": &tokenizer,
|
||||||
} {
|
} {
|
||||||
func() {
|
func() {
|
||||||
if _, err := os.Stat(path.Join(d, k)); os.IsNotExist(err) {
|
*p = path.Base(u)
|
||||||
t.Logf("downloading %s from %s", u, k)
|
if _, err := os.Stat(path.Join(d, *p)); os.IsNotExist(err) {
|
||||||
|
t.Logf("downloading %s from %s", u, *p)
|
||||||
|
|
||||||
resp, err := http.Get(u)
|
resp, err := http.Get(u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -32,7 +44,7 @@ func TestAILocal(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
f, err := os.Create(path.Join(d, k))
|
f, err := os.Create(path.Join(d, *p))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +52,7 @@ func TestAILocal(t *testing.T) {
|
||||||
|
|
||||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
os.Remove(path.Join(d, k))
|
os.Remove(path.Join(d, *p))
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -48,17 +60,24 @@ func TestAILocal(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ai := NewAILocal(
|
ai := NewAILocal(
|
||||||
path.Join(d, "checkpoints"),
|
path.Join(d, checkpoints),
|
||||||
path.Join(d, "tokenizer"),
|
path.Join(d, tokenizer),
|
||||||
0.9,
|
0.9,
|
||||||
256,
|
256,
|
||||||
0.9,
|
0.9,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
testAI(t, ai)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAI(t *testing.T, ai AI) {
|
||||||
|
ctx, can := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer can()
|
||||||
|
|
||||||
t.Run("mvp", func(t *testing.T) {
|
t.Run("mvp", func(t *testing.T) {
|
||||||
if result, err := ai.Do(ctx, "hello world"); err != nil {
|
if result, err := ai.Do(ctx, "hello world"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
} else if len(result) < 250 {
|
} else if len(result) < 3 {
|
||||||
t.Error(result)
|
t.Error(result)
|
||||||
} else {
|
} else {
|
||||||
t.Logf("%s", result)
|
t.Logf("%s", result)
|
||||||
|
|
|
||||||
22
config.go
22
config.go
|
|
@ -21,9 +21,16 @@ type Config struct {
|
||||||
BasicAuthUser string
|
BasicAuthUser string
|
||||||
BasicAuthPassword string
|
BasicAuthPassword string
|
||||||
FillWithTestdata bool
|
FillWithTestdata bool
|
||||||
|
OllamaURL string
|
||||||
|
OllamaModel string
|
||||||
|
LocalCheckpoint string
|
||||||
|
LocalTokenizer string
|
||||||
|
AssetPattern string
|
||||||
|
DatacenterPattern string
|
||||||
storage Storage
|
storage Storage
|
||||||
queue Queue
|
queue Queue
|
||||||
driver Driver
|
driver Driver
|
||||||
|
ai AI
|
||||||
}
|
}
|
||||||
|
|
||||||
func newConfig(ctx context.Context) (Config, error) {
|
func newConfig(ctx context.Context) (Config, error) {
|
||||||
|
|
@ -32,7 +39,10 @@ func newConfig(ctx context.Context) (Config, error) {
|
||||||
|
|
||||||
func newConfigFromEnv(ctx context.Context, getEnv func(string) string) (Config, error) {
|
func newConfigFromEnv(ctx context.Context, getEnv func(string) string) (Config, error) {
|
||||||
def := Config{
|
def := Config{
|
||||||
Port: 8080,
|
Port: 38080,
|
||||||
|
OllamaModel: "gemma:2b",
|
||||||
|
AssetPattern: `(dpg|svc|red)-[a-z0-9-]*`,
|
||||||
|
DatacenterPattern: `[a-z]{4}[a-z]*-[0-9]`,
|
||||||
}
|
}
|
||||||
|
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
|
|
@ -97,12 +107,20 @@ func newConfigFromEnv(ctx context.Context, getEnv func(string) string) (Config,
|
||||||
result.driver = pg
|
result.driver = pg
|
||||||
}
|
}
|
||||||
if result.FillWithTestdata {
|
if result.FillWithTestdata {
|
||||||
if err := FillWithTestdata(ctx, result.driver); err != nil {
|
if err := FillWithTestdata(ctx, result.driver, result.AssetPattern, result.DatacenterPattern); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.storage = NewStorage(result.driver)
|
result.storage = NewStorage(result.driver)
|
||||||
result.queue = NewQueue(result.driver)
|
result.queue = NewQueue(result.driver)
|
||||||
|
|
||||||
|
if result.OllamaURL != "" {
|
||||||
|
result.ai = NewAIOllama(result.OllamaURL, result.OllamaModel)
|
||||||
|
} else if result.LocalCheckpoint != "" && result.LocalTokenizer != "" {
|
||||||
|
result.ai = NewAILocal(result.LocalCheckpoint, result.LocalTokenizer, 0.9, 128, 0.9)
|
||||||
|
} else {
|
||||||
|
result.ai = NewAINoop()
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ type Driver interface {
|
||||||
Set(context.Context, string, string, []byte) error
|
Set(context.Context, string, string, []byte) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func FillWithTestdata(ctx context.Context, driver Driver) error {
|
func FillWithTestdata(ctx context.Context, driver Driver, assetPattern, datacenterPattern string) error {
|
||||||
d := "./testdata/slack_events"
|
d := "./testdata/slack_events"
|
||||||
entries, err := os.ReadDir(d)
|
entries, err := os.ReadDir(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -37,7 +37,7 @@ func FillWithTestdata(ctx context.Context, driver Driver) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m, err := ParseSlack(b)
|
m, err := ParseSlack(b, assetPattern, datacenterPattern)
|
||||||
if errors.Is(err, ErrIrrelevantMessage) {
|
if errors.Is(err, ErrIrrelevantMessage) {
|
||||||
continue
|
continue
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ func TestFillTestdata(t *testing.T) {
|
||||||
defer can()
|
defer can()
|
||||||
|
|
||||||
ram := NewRAM()
|
ram := NewRAM()
|
||||||
if err := FillWithTestdata(ctx, ram); err != nil {
|
if err := FillWithTestdata(ctx, ram, renderAssetPattern, renderDatacenterPattern); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
n := 0
|
n := 0
|
||||||
|
|
|
||||||
9
go.mod
9
go.mod
|
|
@ -6,7 +6,14 @@ require (
|
||||||
github.com/go-errors/errors v1.5.1
|
github.com/go-errors/errors v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/nikolaydubina/llama2.go v0.7.1
|
github.com/nikolaydubina/llama2.go v0.7.1
|
||||||
|
github.com/tmc/langchaingo v0.1.8
|
||||||
go.etcd.io/bbolt v1.3.9
|
go.etcd.io/bbolt v1.3.9
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.4.0 // indirect
|
require (
|
||||||
|
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||||
|
github.com/gage-technologies/mistral-go v1.0.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/pkoukk/tiktoken-go v0.1.6 // indirect
|
||||||
|
golang.org/x/sys v0.16.0 // indirect
|
||||||
|
)
|
||||||
|
|
|
||||||
22
go.sum
22
go.sum
|
|
@ -1,20 +1,30 @@
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||||
|
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/gage-technologies/mistral-go v1.0.0 h1:Hwk0uJO+Iq4kMX/EwbfGRUq9zkO36w7HZ/g53N4N73A=
|
||||||
|
github.com/gage-technologies/mistral-go v1.0.0/go.mod h1:tF++Xt7U975GcLlzhrjSQb8l/x+PrriO9QEdsgm9l28=
|
||||||
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
|
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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/nikolaydubina/llama2.go v0.7.1 h1:ORmH1XbwFYGIOPHprkjtUPOEovlVXhnmnMjbMckaSyE=
|
github.com/nikolaydubina/llama2.go v0.7.1 h1:ORmH1XbwFYGIOPHprkjtUPOEovlVXhnmnMjbMckaSyE=
|
||||||
github.com/nikolaydubina/llama2.go v0.7.1/go.mod h1:ggXhXOaDnEAgSSkcYsomqx/RLjInxe5ZAbcJ+/Y2mTM=
|
github.com/nikolaydubina/llama2.go v0.7.1/go.mod h1:ggXhXOaDnEAgSSkcYsomqx/RLjInxe5ZAbcJ+/Y2mTM=
|
||||||
|
github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw=
|
||||||
|
github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tmc/langchaingo v0.1.8 h1:nrImgh0aWdu3stJTHz80N60WGwPWY8HXCK10gQny7bA=
|
||||||
|
github.com/tmc/langchaingo v0.1.8/go.mod h1:iNBfS9e6jxBKsJSPWnlqNhoVWgdA3D1g5cdFJjbIZNQ=
|
||||||
go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
|
go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
|
||||||
go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
|
go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
|
||||||
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||||
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
178
main.go
178
main.go
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
@ -64,6 +65,9 @@ func newHandler(cfg Config) http.HandlerFunc {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.Handle("POST /api/v1/events/slack", http.HandlerFunc(newHandlerPostAPIV1EventsSlack(cfg)))
|
mux.Handle("POST /api/v1/events/slack", http.HandlerFunc(newHandlerPostAPIV1EventsSlack(cfg)))
|
||||||
|
mux.Handle("GET /api/v1/eventnames", http.HandlerFunc(newHandlerGetAPIV1EventNames(cfg)))
|
||||||
|
mux.Handle("GET /api/v1/events", http.HandlerFunc(newHandlerGetAPIV1Events(cfg)))
|
||||||
|
mux.Handle("GET /api/v1/messages", http.HandlerFunc(newHandlerGetAPIV1Messages(cfg)))
|
||||||
mux.Handle("GET /api/v1/threads", http.HandlerFunc(newHandlerGetAPIV1Threads(cfg)))
|
mux.Handle("GET /api/v1/threads", http.HandlerFunc(newHandlerGetAPIV1Threads(cfg)))
|
||||||
mux.Handle("GET /api/v1/threads/{thread}", http.HandlerFunc(newHandlerGetAPIV1ThreadsThread(cfg)))
|
mux.Handle("GET /api/v1/threads/{thread}", http.HandlerFunc(newHandlerGetAPIV1ThreadsThread(cfg)))
|
||||||
|
|
||||||
|
|
@ -78,19 +82,82 @@ func newHandler(cfg Config) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newHandlerGetAPIV1EventNames(cfg Config) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !basicAuth(cfg, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
since, err := parseSince(r.URL.Query().Get("since"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eventNames, err := cfg.storage.EventNamesSince(r.Context(), since)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encodeResponse(w, r, map[string]any{"eventNames": eventNames})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHandlerGetAPIV1Events(cfg Config) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !basicAuth(cfg, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
since, err := parseSince(r.URL.Query().Get("since"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := cfg.storage.EventsSince(r.Context(), since)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encodeResponse(w, r, map[string]any{"events": events})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHandlerGetAPIV1Messages(cfg Config) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !basicAuth(cfg, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
since, err := parseSince(r.URL.Query().Get("since"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := cfg.storage.MessagesSince(r.Context(), since)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encodeResponse(w, r, map[string]any{"messages": messages})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newHandlerGetAPIV1Threads(cfg Config) http.HandlerFunc {
|
func newHandlerGetAPIV1Threads(cfg Config) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !basicAuth(cfg, w, r) {
|
if !basicAuth(cfg, w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
since := time.Unix(0, 0)
|
since, err := parseSince(r.URL.Query().Get("since"))
|
||||||
if sinceS := r.URL.Query().Get("since"); sinceS == "" {
|
if err != nil {
|
||||||
} else if n, err := strconv.ParseInt(sinceS, 10, 64); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
} else {
|
|
||||||
since = time.Unix(n, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
threads, err := cfg.storage.ThreadsSince(r.Context(), since)
|
threads, err := cfg.storage.ThreadsSince(r.Context(), since)
|
||||||
|
|
@ -99,7 +166,7 @@ func newHandlerGetAPIV1Threads(cfg Config) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"threads": threads})
|
encodeResponse(w, r, map[string]any{"threads": threads})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,7 +184,7 @@ func newHandlerGetAPIV1ThreadsThread(cfg Config) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"thread": map[string]any{"messages": messages}})
|
encodeResponse(w, r, map[string]any{"thread": messages})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +215,7 @@ func handlerPostAPIV1EventsSlackInitialize(w http.ResponseWriter, r *http.Reques
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"challenge": challenge.Challenge})
|
encodeResponse(w, r, map[string]any{"challenge": challenge.Challenge})
|
||||||
}
|
}
|
||||||
|
|
||||||
func _newHandlerPostAPIV1EventsSlack(cfg Config) http.HandlerFunc {
|
func _newHandlerPostAPIV1EventsSlack(cfg Config) http.HandlerFunc {
|
||||||
|
|
@ -179,7 +246,7 @@ func _newHandlerPostAPIV1EventsSlack(cfg Config) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m, err := ParseSlack(b)
|
m, err := ParseSlack(b, cfg.AssetPattern, cfg.DatacenterPattern)
|
||||||
if errors.Is(err, ErrIrrelevantMessage) {
|
if errors.Is(err, ErrIrrelevantMessage) {
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
|
|
@ -195,3 +262,96 @@ func _newHandlerPostAPIV1EventsSlack(cfg Config) http.HandlerFunc {
|
||||||
log.Printf("ingested %v", m.ID)
|
log.Printf("ingested %v", m.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseSince(s string) (time.Time, error) {
|
||||||
|
if s == "" {
|
||||||
|
return time.Unix(0, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, err := strconv.ParseInt(s, 10, 64); err != nil {
|
||||||
|
} else {
|
||||||
|
return time.Unix(n, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err != nil {
|
||||||
|
} else {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, s); err != nil {
|
||||||
|
} else {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if t, err := time.ParseInLocation(time.DateOnly, s, time.Local); err != nil {
|
||||||
|
} else {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Time{}, fmt.Errorf("failed to parse since=%q", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeResponse(w http.ResponseWriter, r *http.Request, v interface{}) error {
|
||||||
|
if strings.Contains(r.Header.Get("Accept"), "text/csv") {
|
||||||
|
return encodeCSVResponse(w, v)
|
||||||
|
}
|
||||||
|
if strings.Contains(r.Header.Get("Accept"), "text/tsv") {
|
||||||
|
return encodeTSVResponse(w, v)
|
||||||
|
}
|
||||||
|
return encodeJSONResponse(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeJSONResponse(w http.ResponseWriter, v interface{}) error {
|
||||||
|
return json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeTSVResponse(w http.ResponseWriter, v interface{}) error {
|
||||||
|
return encodeSVResponse(w, v, "\t")
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeCSVResponse(w http.ResponseWriter, v interface{}) error {
|
||||||
|
return encodeSVResponse(w, v, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeSVResponse(w http.ResponseWriter, v interface{}, delim string) error {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string][]map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(b, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var objects []map[string]json.RawMessage
|
||||||
|
for k := range data {
|
||||||
|
objects = data[k]
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []string{}
|
||||||
|
for i := range objects {
|
||||||
|
for k := range objects[i] {
|
||||||
|
b, _ := json.Marshal(k)
|
||||||
|
fields = append(fields, string(b))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sort.Strings(fields)
|
||||||
|
|
||||||
|
w.Write([]byte(strings.Join(fields, delim)))
|
||||||
|
w.Write([]byte("\n"))
|
||||||
|
|
||||||
|
for _, object := range objects {
|
||||||
|
for j, field := range fields {
|
||||||
|
json.Unmarshal([]byte(field), &field)
|
||||||
|
if j > 0 {
|
||||||
|
w.Write([]byte(delim))
|
||||||
|
}
|
||||||
|
w.Write(object[field])
|
||||||
|
}
|
||||||
|
w.Write([]byte("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
122
main_test.go
122
main_test.go
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -80,6 +81,75 @@ func TestRun(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/v1/messages", func(t *testing.T) {
|
||||||
|
resp, err := http.Get(fmt.Sprintf("%s/api/v1/messages", u))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("(%d) %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Messages []Message
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if len(result.Messages) != 1 {
|
||||||
|
t.Fatal(result.Messages)
|
||||||
|
} else {
|
||||||
|
t.Logf("%+v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/v1/eventnames", func(t *testing.T) {
|
||||||
|
resp, err := http.Get(fmt.Sprintf("%s/api/v1/eventnames", u))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("(%d) %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
EventNames []string
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if result.EventNames[0] != "[Oregon-1] Wal Receive Count Alert" {
|
||||||
|
t.Fatal(result.EventNames)
|
||||||
|
} else {
|
||||||
|
t.Logf("%+v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /api/v1/events", func(t *testing.T) {
|
||||||
|
resp, err := http.Get(fmt.Sprintf("%s/api/v1/events", u))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("(%d) %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Events []string
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if result.Events[0] != "11067" {
|
||||||
|
t.Fatal(result.Events)
|
||||||
|
} else {
|
||||||
|
t.Logf("%+v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("GET /api/v1/threads", func(t *testing.T) {
|
t.Run("GET /api/v1/threads", func(t *testing.T) {
|
||||||
resp, err := http.Get(fmt.Sprintf("%s/api/v1/threads", u))
|
resp, err := http.Get(fmt.Sprintf("%s/api/v1/threads", u))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -98,6 +168,8 @@ func TestRun(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
} else if result.Threads[0] != "1712911957.023359" {
|
} else if result.Threads[0] != "1712911957.023359" {
|
||||||
t.Fatal(result.Threads)
|
t.Fatal(result.Threads)
|
||||||
|
} else {
|
||||||
|
t.Logf("%+v", result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -114,16 +186,56 @@ func TestRun(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Thread struct {
|
Thread []Message
|
||||||
Messages []Message
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
} else if len(result.Thread.Messages) != 1 {
|
} else if len(result.Thread) != 1 {
|
||||||
t.Fatal(result.Thread)
|
t.Fatal(result.Thread)
|
||||||
} else {
|
} else {
|
||||||
t.Logf("%+v", result.Thread.Messages[0])
|
t.Logf("%+v", result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("CSV GET /api/v1/threads/1712911957.023359", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/threads/1712911957.023359", u), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "text/csv")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("(%d) %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := csv.NewReader(resp.Body)
|
||||||
|
var lastLine []string
|
||||||
|
for {
|
||||||
|
line, err := dec.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastLine == nil {
|
||||||
|
} else if len(lastLine) != len(line) {
|
||||||
|
t.Errorf("last line had %v elements but this line has %v", len(lastLine), len(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("%+v", line)
|
||||||
|
lastLine = line
|
||||||
|
}
|
||||||
|
if lastLine == nil {
|
||||||
|
t.Error("no lines found")
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
message.go
73
message.go
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -13,16 +14,17 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string
|
ID string
|
||||||
TS uint64
|
TS uint64
|
||||||
Source string
|
Source string
|
||||||
Channel string
|
Channel string
|
||||||
Thread string
|
Thread string
|
||||||
EventName string
|
EventName string
|
||||||
Event string
|
Event string
|
||||||
Plaintext string
|
Plaintext string
|
||||||
Asset string
|
Asset string
|
||||||
Resolved bool
|
Resolved bool
|
||||||
|
Datacenter string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Message) Empty() bool {
|
func (m Message) Empty() bool {
|
||||||
|
|
@ -105,7 +107,10 @@ type (
|
||||||
slackAction struct{}
|
slackAction struct{}
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParseSlack(b []byte) (Message, error) {
|
func ParseSlack(b []byte, assetPattern, datacenterPattern string) (Message, error) {
|
||||||
|
asset := regexp.MustCompile(assetPattern)
|
||||||
|
datacenter := regexp.MustCompile(datacenterPattern)
|
||||||
|
|
||||||
s, err := parseSlack(b)
|
s, err := parseSlack(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
|
|
@ -117,17 +122,24 @@ func ParseSlack(b []byte) (Message, error) {
|
||||||
} else if !strings.Contains(s.Event.Attachments[0].Title, ": Firing: ") {
|
} else if !strings.Contains(s.Event.Attachments[0].Title, ": Firing: ") {
|
||||||
return Message{}, ErrIrrelevantMessage
|
return Message{}, ErrIrrelevantMessage
|
||||||
}
|
}
|
||||||
|
var tagsField string
|
||||||
|
for _, field := range s.Event.Attachments[0].Fields {
|
||||||
|
if field.Title == "Tags" {
|
||||||
|
tagsField = field.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
return Message{
|
return Message{
|
||||||
ID: fmt.Sprintf("%s/%v", s.Event.ID, s.TS),
|
ID: fmt.Sprintf("%s/%v", s.Event.ID, s.TS),
|
||||||
TS: s.TS,
|
TS: s.TS,
|
||||||
Source: fmt.Sprintf(`https://renderinc.slack.com/archives/%s/p%s`, s.Event.Channel, strings.ReplaceAll(s.Event.ID, ".", "")),
|
Source: fmt.Sprintf(`https://renderinc.slack.com/archives/%s/p%s`, s.Event.Channel, strings.ReplaceAll(s.Event.ID, ".", "")),
|
||||||
Channel: s.Event.Channel,
|
Channel: s.Event.Channel,
|
||||||
Thread: s.Event.ID,
|
Thread: s.Event.ID,
|
||||||
EventName: strings.Split(s.Event.Attachments[0].Title, ": Firing: ")[1],
|
EventName: strings.Split(s.Event.Attachments[0].Title, ": Firing: ")[1],
|
||||||
Event: strings.Split(s.Event.Attachments[0].Title, ":")[0],
|
Event: strings.TrimPrefix(strings.Split(s.Event.Attachments[0].Title, ":")[0], "#"),
|
||||||
Plaintext: s.Event.Attachments[0].Text,
|
Plaintext: s.Event.Attachments[0].Text,
|
||||||
Asset: "TODO",
|
Asset: asset.FindString(s.Event.Attachments[0].Text),
|
||||||
Resolved: !strings.HasPrefix(s.Event.Attachments[0].Color, "F"),
|
Resolved: !strings.HasPrefix(s.Event.Attachments[0].Color, "F"),
|
||||||
|
Datacenter: datacenter.FindString(tagsField),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,15 +147,16 @@ func ParseSlack(b []byte) (Message, error) {
|
||||||
return Message{}, ErrIrrelevantMessage
|
return Message{}, ErrIrrelevantMessage
|
||||||
}
|
}
|
||||||
return Message{
|
return Message{
|
||||||
ID: fmt.Sprintf("%s/%v", s.Event.ParentID, s.TS),
|
ID: fmt.Sprintf("%s/%v", s.Event.ParentID, s.TS),
|
||||||
TS: s.TS,
|
TS: s.TS,
|
||||||
Source: fmt.Sprintf(`https://renderinc.slack.com/archives/%s/p%s`, s.Event.Channel, strings.ReplaceAll(s.Event.ParentID, ".", "")),
|
Source: fmt.Sprintf(`https://renderinc.slack.com/archives/%s/p%s`, s.Event.Channel, strings.ReplaceAll(s.Event.ParentID, ".", "")),
|
||||||
Channel: s.Event.Channel,
|
Channel: s.Event.Channel,
|
||||||
Thread: s.Event.ParentID,
|
Thread: s.Event.ParentID,
|
||||||
EventName: "TODO",
|
EventName: "",
|
||||||
Event: "TODO",
|
Event: "",
|
||||||
Plaintext: s.Event.Text,
|
Plaintext: s.Event.Text,
|
||||||
Asset: "TODO",
|
Asset: asset.FindString(s.Event.Text),
|
||||||
|
Datacenter: datacenter.FindString(s.Event.Text),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
renderAssetPattern = `(dpg|svc|red)-[a-z0-9-]*[a-z0-9]`
|
||||||
|
renderDatacenterPattern = `[a-z]{4}[a-z]*-[0-9]`
|
||||||
|
)
|
||||||
|
|
||||||
func TestParseSlackTestdata(t *testing.T) {
|
func TestParseSlackTestdata(t *testing.T) {
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
slackMessage slackMessage
|
slackMessage slackMessage
|
||||||
|
|
@ -39,10 +44,10 @@ func TestParseSlackTestdata(t *testing.T) {
|
||||||
Source: "https://renderinc.slack.com/archives/C06U1DDBBU4/p1712927439728409",
|
Source: "https://renderinc.slack.com/archives/C06U1DDBBU4/p1712927439728409",
|
||||||
Channel: "C06U1DDBBU4",
|
Channel: "C06U1DDBBU4",
|
||||||
Thread: "1712927439.728409",
|
Thread: "1712927439.728409",
|
||||||
EventName: "TODO",
|
EventName: "",
|
||||||
Event: "TODO",
|
Event: "",
|
||||||
Plaintext: "I gotta do this",
|
Plaintext: "I gotta do this",
|
||||||
Asset: "TODO",
|
Asset: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"opsgenie_alert.json": {
|
"opsgenie_alert.json": {
|
||||||
|
|
@ -74,9 +79,9 @@ func TestParseSlackTestdata(t *testing.T) {
|
||||||
Channel: "C06U1DDBBU4",
|
Channel: "C06U1DDBBU4",
|
||||||
Thread: "1712927439.728409",
|
Thread: "1712927439.728409",
|
||||||
EventName: "Alertconfig Workflow Failed",
|
EventName: "Alertconfig Workflow Failed",
|
||||||
Event: "#11071",
|
Event: "11071",
|
||||||
Plaintext: "At least one alertconfig run has failed unexpectedly.\nDashboard: <https://grafana.render.com/d/VLZU83YVk?orgId=1>\nPanel: <https://grafana.render.com/d/VLZU83YVk?orgId=1&viewPanel=17>\nSource: <https://grafana.render.com/alerting/grafana/fa7b06b8-b4d8-4979-bce7-5e1c432edd81/view?orgId=1>",
|
Plaintext: "At least one alertconfig run has failed unexpectedly.\nDashboard: <https://grafana.render.com/d/VLZU83YVk?orgId=1>\nPanel: <https://grafana.render.com/d/VLZU83YVk?orgId=1&viewPanel=17>\nSource: <https://grafana.render.com/alerting/grafana/fa7b06b8-b4d8-4979-bce7-5e1c432edd81/view?orgId=1>",
|
||||||
Asset: "TODO",
|
Asset: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"opsgenie_alert_resolved.json": {
|
"opsgenie_alert_resolved.json": {
|
||||||
|
|
@ -108,9 +113,9 @@ func TestParseSlackTestdata(t *testing.T) {
|
||||||
Channel: "C06U1DDBBU4",
|
Channel: "C06U1DDBBU4",
|
||||||
Thread: "1712916339.000300",
|
Thread: "1712916339.000300",
|
||||||
EventName: "Alertconfig Workflow Failed",
|
EventName: "Alertconfig Workflow Failed",
|
||||||
Event: "#11069",
|
Event: "11069",
|
||||||
Plaintext: "At least one alertconfig run has failed unexpectedly.\nDashboard: <https://grafana.render.com/d/VLZU83YVk?orgId=1>\nPanel: <https://grafana.render.com/d/VLZU83YVk?orgId=1&viewPanel=17>\nSource: <https://grafana.render.com/alerting/grafana/fa7b06b8-b4d8-4979-bce7-5e1c432edd81/view?orgId=1>",
|
Plaintext: "At least one alertconfig run has failed unexpectedly.\nDashboard: <https://grafana.render.com/d/VLZU83YVk?orgId=1>\nPanel: <https://grafana.render.com/d/VLZU83YVk?orgId=1&viewPanel=17>\nSource: <https://grafana.render.com/alerting/grafana/fa7b06b8-b4d8-4979-bce7-5e1c432edd81/view?orgId=1>",
|
||||||
Asset: "TODO",
|
Asset: "",
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -135,7 +140,7 @@ func TestParseSlackTestdata(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ParseSlack", func(t *testing.T) {
|
t.Run("ParseSlack", func(t *testing.T) {
|
||||||
got, err := ParseSlack(b)
|
got, err := ParseSlack(b, renderAssetPattern, renderDatacenterPattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"io"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed report.tmpl
|
||||||
|
var reportTMPL string
|
||||||
|
|
||||||
|
func ReportSince(ctx context.Context, w io.Writer, s Storage, t time.Time) error {
|
||||||
|
tmpl, err := template.New("report").Parse(reportTMPL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := s.MessagesSince(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
threads, err := s.ThreadsSince(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
eventNames, err := s.EventNamesSince(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := s.EventsSince(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpl.Execute(w, map[string]any{
|
||||||
|
"since": t.Format("2006-01-02"),
|
||||||
|
"messages": messages,
|
||||||
|
"threads": threads,
|
||||||
|
"events": events,
|
||||||
|
"eventNames": eventNames,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<header>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
|
||||||
|
</header>
|
||||||
|
<body>
|
||||||
|
<h1>Report</h1>
|
||||||
|
</body>
|
||||||
|
<footer>
|
||||||
|
</footer>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReport(t *testing.T) {
|
||||||
|
ctx, can := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer can()
|
||||||
|
|
||||||
|
w := bytes.NewBuffer(nil)
|
||||||
|
|
||||||
|
db := NewRAM()
|
||||||
|
FillWithTestdata(ctx, db, renderAssetPattern, renderDatacenterPattern)
|
||||||
|
s := NewStorage(db)
|
||||||
|
|
||||||
|
if err := ReportSince(ctx, w, s, time.Now().Add(-1*time.Hour*24*365*20)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := path.Join(os.TempDir(), "test_report.html")
|
||||||
|
if env := os.Getenv("TEST_REPORT_PATH"); env != "" {
|
||||||
|
p = env
|
||||||
|
}
|
||||||
|
os.WriteFile(p, w.Bytes(), os.ModePerm)
|
||||||
|
t.Log(p)
|
||||||
|
}
|
||||||
30
storage.go
30
storage.go
|
|
@ -19,23 +19,39 @@ func NewStorage(driver Driver) Storage {
|
||||||
return Storage{driver: driver}
|
return Storage{driver: driver}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s Storage) MessagesSince(ctx context.Context, t time.Time) ([]Message, error) {
|
||||||
|
return s.messagesWhere(ctx, func(m Message) bool {
|
||||||
|
return !t.After(m.Time())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s Storage) Threads(ctx context.Context) ([]string, error) {
|
func (s Storage) Threads(ctx context.Context) ([]string, error) {
|
||||||
return s.ThreadsSince(ctx, time.Unix(0, 0))
|
return s.ThreadsSince(ctx, time.Unix(0, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Storage) ThreadsSince(ctx context.Context, t time.Time) ([]string, error) {
|
func (s Storage) ThreadsSince(ctx context.Context, t time.Time) ([]string, error) {
|
||||||
messages, err := s.messagesWhere(ctx, func(m Message) bool {
|
return s.fieldsSince(ctx, t, func(m Message) string { return m.Thread })
|
||||||
return !t.After(m.Time())
|
}
|
||||||
})
|
|
||||||
|
func (s Storage) EventNamesSince(ctx context.Context, t time.Time) ([]string, error) {
|
||||||
|
return s.fieldsSince(ctx, t, func(m Message) string { return m.EventName })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Storage) EventsSince(ctx context.Context, t time.Time) ([]string, error) {
|
||||||
|
return s.fieldsSince(ctx, t, func(m Message) string { return m.Event })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Storage) fieldsSince(ctx context.Context, t time.Time, fielder func(Message) string) ([]string, error) {
|
||||||
|
messages, err := s.MessagesSince(ctx, t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
threads := map[string]struct{}{}
|
values := map[string]struct{}{}
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
threads[m.Thread] = struct{}{}
|
values[fielder(m)] = struct{}{}
|
||||||
}
|
}
|
||||||
result := make([]string, 0, len(threads))
|
result := make([]string, 0, len(values))
|
||||||
for k := range threads {
|
for k := range values {
|
||||||
result = append(result, k)
|
result = append(result, k)
|
||||||
}
|
}
|
||||||
sort.Strings(result)
|
sort.Strings(result)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue