feat: proxmox idlewatcher (#88)

* feat: idle sleep for proxmox LXCs

* refactor: replace deprecated docker api types

* chore(api): remove debug task list endpoint

* refactor: move servemux to gphttp/servemux; favicon.go to v1/favicon

* refactor: introduce Pool interface, move agent_pool to agent module

* refactor: simplify api code

* feat: introduce debug api

* refactor: remove net.URL and net.CIDR types, improved unmarshal handling

* chore: update Makefile for debug build tag, update README

* chore: add gperr.Unwrap method

* feat: relative time and duration formatting

* chore: add ROOT_DIR environment variable, refactor

* migration: move homepage override and icon cache to $BASE_DIR/data, add migration code

* fix: nil dereference on marshalling service health

* fix: wait for route deletion

* chore: enhance tasks debuggability

* feat: stdout access logger and MultiWriter

* fix(agent): remove agent properly on verify error

* fix(metrics): disk exclusion logic and added corresponding tests

* chore: update schema and prettify, fix package.json and Makefile

* fix: I/O buffer not being shrunk before putting back to pool

* feat: enhanced error handling module

* chore: deps upgrade

* feat: better value formatting and handling

---------

Co-authored-by: yusing <yusing@6uo.me>
This commit is contained in:
Yuzerion
2025-04-16 14:52:33 +08:00
committed by GitHub
parent 88f3a95b61
commit 57292f0fe8
173 changed files with 4131 additions and 2096 deletions

View File

@@ -85,33 +85,6 @@ func (m Map[KT, VT]) CollectErrors(do func(k KT, v VT) error) []error {
return errs
}
// CollectErrors calls the given function for each key-value pair in the map,
// then returns a slice of errors collected.
func (m Map[KT, VT]) CollectErrorsParallel(do func(k KT, v VT) error) []error {
if m.Size() < minParallelSize {
return m.CollectErrors(do)
}
var errs []error
var mu sync.Mutex
var wg sync.WaitGroup
m.Range(func(k KT, v VT) bool {
wg.Add(1)
go func() {
if err := do(k, v); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
wg.Done()
}()
return true
})
wg.Wait()
return errs
}
func (m Map[KT, VT]) Has(k KT) bool {
_, ok := m.Load(k)
return ok

View File

@@ -145,7 +145,7 @@ func CopyClose(dst *ContextWriter, src *ContextReader) (err error) {
buf = make([]byte, 0, size)
} else {
buf = copyBufPool.Get().([]byte)
defer copyBufPool.Put(buf)
defer copyBufPool.Put(buf[:0])
}
// close both as soon as one of them is done
wCloser, wCanClose := dst.Writer.(io.Closer)

View File

@@ -0,0 +1,74 @@
package pool
import (
"sort"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/functional"
)
type (
Pool[T Object] struct {
m functional.Map[string, T]
name string
}
Object interface {
Key() string
Name() string
utils.MapMarshaler
}
)
func New[T Object](name string) Pool[T] {
return Pool[T]{functional.NewMapOf[string, T](), name}
}
func (p Pool[T]) Name() string {
return p.name
}
func (p Pool[T]) Add(obj T) {
p.m.Store(obj.Key(), obj)
logging.Info().Msgf("%s: added %s", p.name, obj.Name())
}
func (p Pool[T]) Del(obj T) {
p.m.Delete(obj.Key())
logging.Info().Msgf("%s: removed %s", p.name, obj.Name())
}
func (p Pool[T]) Get(key string) (T, bool) {
return p.m.Load(key)
}
func (p Pool[T]) Size() int {
return p.m.Size()
}
func (p Pool[T]) Clear() {
p.m.Clear()
}
func (p Pool[T]) Base() functional.Map[string, T] {
return p.m
}
func (p Pool[T]) Slice() []T {
slice := make([]T, 0, p.m.Size())
for _, v := range p.m.Range {
slice = append(slice, v)
}
sort.Slice(slice, func(i, j int) bool {
return slice[i].Name() < slice[j].Name()
})
return slice
}
func (p Pool[T]) Iter(fn func(k string, v T) bool) {
p.m.Range(fn)
}
func (p Pool[T]) IterAll(fn func(k string, v T)) {
p.m.RangeAll(fn)
}

View File

@@ -3,6 +3,8 @@ package utils
import (
"encoding/json"
"errors"
"net"
"net/url"
"os"
"reflect"
"strconv"
@@ -18,9 +20,14 @@ import (
type SerializedObject = map[string]any
type MapUnmarshaller interface {
UnmarshalMap(m map[string]any) gperr.Error
}
type (
MapMarshaler interface {
MarshalMap() map[string]any
}
MapUnmarshaller interface {
UnmarshalMap(m map[string]any) gperr.Error
}
)
var (
ErrInvalidType = gperr.New("invalid type")
@@ -37,7 +44,19 @@ var (
tagAliases = "aliases" // declare aliases for fields
)
var mapUnmarshalerType = reflect.TypeFor[MapUnmarshaller]()
var (
typeDuration = reflect.TypeFor[time.Duration]()
typeTime = reflect.TypeFor[time.Time]()
typeURL = reflect.TypeFor[url.URL]()
typeCIDR = reflect.TypeFor[net.IPNet]()
typeMapMarshaller = reflect.TypeFor[MapMarshaler]()
typeMapUnmarshaler = reflect.TypeFor[MapUnmarshaller]()
typeJSONMarshaller = reflect.TypeFor[json.Marshaler]()
typeStrParser = reflect.TypeFor[strutils.Parser]()
typeAny = reflect.TypeOf((*any)(nil)).Elem()
)
var defaultValues = functional.NewMapOf[reflect.Type, func() any]()
@@ -191,7 +210,7 @@ func MapUnmarshalValidate(src SerializedObject, dst any) (err gperr.Error) {
return gperr.Errorf("unmarshal: src is %w and dst is not settable", ErrNilValue)
}
if dstT.Implements(mapUnmarshalerType) {
if dstT.Implements(typeMapUnmarshaler) {
dstV, _, err = dive(dstV)
if err != nil {
return err
@@ -289,6 +308,20 @@ func isIntFloat(t reflect.Kind) bool {
return t >= reflect.Bool && t <= reflect.Float64
}
func itoa(v reflect.Value) string {
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(v.Int(), 10)
case reflect.Bool:
return strconv.FormatBool(v.Bool())
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return strconv.FormatUint(v.Uint(), 10)
case reflect.Float32, reflect.Float64:
return strconv.FormatFloat(v.Float(), 'f', -1, 64)
}
panic("invalid call on itoa")
}
// Convert attempts to convert the src to dst.
//
// If src is a map, it is deserialized into dst.
@@ -345,27 +378,25 @@ func Convert(src reflect.Value, dst reflect.Value) gperr.Error {
return err
}
case isIntFloat(srcKind):
var strV string
switch {
case src.CanInt():
strV = strconv.FormatInt(src.Int(), 10)
case srcKind == reflect.Bool:
strV = strconv.FormatBool(src.Bool())
case src.CanUint():
strV = strconv.FormatUint(src.Uint(), 10)
case src.CanFloat():
strV = strconv.FormatFloat(src.Float(), 'f', -1, 64)
if dst.Kind() == reflect.String {
dst.Set(reflect.ValueOf(itoa(src)))
return nil
}
if convertible, err := ConvertString(strV, dst); convertible {
return err
if dst.Addr().Type().Implements(typeStrParser) {
return Convert(reflect.ValueOf(itoa(src)), dst)
}
if !isIntFloat(dstT.Kind()) || !src.CanConvert(dstT) {
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT)
}
dst.Set(src.Convert(dstT))
return nil
case srcKind == reflect.Map:
if src.Len() == 0 {
return nil
}
obj, ok := src.Interface().(SerializedObject)
if !ok {
return ErrUnsupportedConversion.Subject(dstT.String() + " to " + srcT.String())
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT)
}
return MapUnmarshalValidate(obj, dst.Addr().Interface())
case srcKind == reflect.Slice:
@@ -373,7 +404,7 @@ func Convert(src reflect.Value, dst reflect.Value) gperr.Error {
return nil
}
if dstT.Kind() != reflect.Slice {
return ErrUnsupportedConversion.Subject(dstT.String() + " to " + srcT.String())
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT)
}
sliceErrs := gperr.NewBuilder("slice conversion errors")
newSlice := reflect.MakeSlice(dstT, src.Len(), src.Len())
@@ -397,6 +428,19 @@ func Convert(src reflect.Value, dst reflect.Value) gperr.Error {
return ErrUnsupportedConversion.Subjectf("%s to %s", srcT, dstT)
}
func isSameOrEmbededType(src, dst reflect.Type) bool {
return src == dst || src.ConvertibleTo(dst)
}
func setSameOrEmbedddType(src, dst reflect.Value) {
dstT := dst.Type()
if src.Type().AssignableTo(dstT) {
dst.Set(src)
} else {
dst.Set(src.Convert(dstT))
}
}
func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gperr.Error) {
convertible = true
dstT := dst.Type()
@@ -407,16 +451,17 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
dst = dst.Elem()
dstT = dst.Type()
}
if dst.Kind() == reflect.String {
dstKind := dst.Kind()
if dstKind == reflect.String {
dst.SetString(src)
return
}
switch dstT {
case reflect.TypeFor[time.Duration]():
if src == "" {
dst.Set(reflect.Zero(dstT))
return
}
if src == "" {
dst.Set(reflect.Zero(dstT))
return
}
switch {
case dstT == typeDuration:
d, err := time.ParseDuration(src)
if err != nil {
return true, gperr.Wrap(err)
@@ -426,9 +471,25 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
}
dst.Set(reflect.ValueOf(d))
return
default:
case isSameOrEmbededType(dstT, typeURL):
u, err := url.Parse(src)
if err != nil {
return true, gperr.Wrap(err)
}
setSameOrEmbedddType(reflect.ValueOf(u).Elem(), dst)
return
case isSameOrEmbededType(dstT, typeCIDR):
if !strings.ContainsRune(src, '/') {
src += "/32" // single IP
}
_, ipnet, err := net.ParseCIDR(src)
if err != nil {
return true, gperr.Wrap(err)
}
setSameOrEmbedddType(reflect.ValueOf(ipnet).Elem(), dst)
return
}
if dstKind := dst.Kind(); isIntFloat(dstKind) {
if isIntFloat(dstKind) {
var i any
var err error
switch {
@@ -458,7 +519,14 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
}
// yaml like
var tmp any
switch dst.Kind() {
switch dstKind {
case reflect.Map, reflect.Struct:
rawMap := make(SerializedObject)
err := yaml.Unmarshal([]byte(src), &rawMap)
if err != nil {
return true, gperr.Wrap(err)
}
tmp = rawMap
case reflect.Slice:
src = strings.TrimSpace(src)
isMultiline := strings.ContainsRune(src, '\n')
@@ -484,13 +552,6 @@ func ConvertString(src string, dst reflect.Value) (convertible bool, convErr gpe
return true, gperr.Wrap(err)
}
tmp = sl
case reflect.Map, reflect.Struct:
rawMap := make(SerializedObject)
err := yaml.Unmarshal([]byte(src), &rawMap)
if err != nil {
return true, gperr.Wrap(err)
}
tmp = rawMap
default:
return false, nil
}

View File

@@ -1,6 +1,9 @@
package utils
import (
"fmt"
"net"
"net/url"
"reflect"
"strconv"
"testing"
@@ -9,7 +12,7 @@ import (
"gopkg.in/yaml.v3"
)
func TestDeserialize(t *testing.T) {
func TestUnmarshal(t *testing.T) {
type S struct {
I int
S string
@@ -38,15 +41,15 @@ func TestDeserialize(t *testing.T) {
}
)
t.Run("deserialize", func(t *testing.T) {
t.Run("unmarshal", func(t *testing.T) {
var s2 S
err := MapUnmarshalValidate(testStructSerialized, &s2)
ExpectNoError(t, err)
ExpectEqual(t, s2, testStruct)
ExpectEqualValues(t, s2, testStruct)
})
}
func TestDeserializeAnonymousField(t *testing.T) {
func TestUnmarshalAnonymousField(t *testing.T) {
type Anon struct {
A, B int
}
@@ -62,71 +65,43 @@ func TestDeserializeAnonymousField(t *testing.T) {
// t.Fatalf("anon %v, all %v", anon, all)
err := MapUnmarshalValidate(map[string]any{"a": 1, "b": 2, "c": 3}, &s)
ExpectNoError(t, err)
ExpectEqual(t, s.A, 1)
ExpectEqual(t, s.B, 2)
ExpectEqual(t, s.C, 3)
ExpectEqualValues(t, s.A, 1)
ExpectEqualValues(t, s.B, 2)
ExpectEqualValues(t, s.C, 3)
err = MapUnmarshalValidate(map[string]any{"a": 1, "b": 2, "c": 3}, &s2)
ExpectNoError(t, err)
ExpectEqual(t, s2.A, 1)
ExpectEqual(t, s2.B, 2)
ExpectEqual(t, s2.C, 3)
ExpectEqualValues(t, s2.A, 1)
ExpectEqualValues(t, s2.B, 2)
ExpectEqualValues(t, s2.C, 3)
}
func TestStringIntConvert(t *testing.T) {
s := "127"
test := struct {
i8 int8
i16 int16
i32 int32
i64 int64
u8 uint8
u16 uint16
u32 uint32
u64 uint64
I8 int8
I16 int16
I32 int32
I64 int64
U8 uint8
U16 uint16
U32 uint32
U64 uint64
}{}
ok, err := ConvertString(s, reflect.ValueOf(&test.i8))
refl := reflect.ValueOf(&test)
for i := range refl.Elem().NumField() {
field := refl.Elem().Field(i)
t.Run(fmt.Sprintf("field_%s", field.Type().Name()), func(t *testing.T) {
ok, err := ConvertString("127", field)
ExpectTrue(t, ok)
ExpectNoError(t, err)
ExpectEqualValues(t, field.Interface(), 127)
ExpectTrue(t, ok)
ExpectNoError(t, err)
ExpectEqual(t, test.i8, int8(127))
ok, err = ConvertString(s, reflect.ValueOf(&test.i16))
ExpectTrue(t, ok)
ExpectNoError(t, err)
ExpectEqual(t, test.i16, int16(127))
ok, err = ConvertString(s, reflect.ValueOf(&test.i32))
ExpectTrue(t, ok)
ExpectNoError(t, err)
ExpectEqual(t, test.i32, int32(127))
ok, err = ConvertString(s, reflect.ValueOf(&test.i64))
ExpectTrue(t, ok)
ExpectNoError(t, err)
ExpectEqual(t, test.i64, int64(127))
ok, err = ConvertString(s, reflect.ValueOf(&test.u8))
ExpectTrue(t, ok)
ExpectNoError(t, err)
ExpectEqual(t, test.u8, uint8(127))
ok, err = ConvertString(s, reflect.ValueOf(&test.u16))
ExpectTrue(t, ok)
ExpectNoError(t, err)
ExpectEqual(t, test.u16, uint16(127))
ok, err = ConvertString(s, reflect.ValueOf(&test.u32))
ExpectTrue(t, ok)
ExpectNoError(t, err)
ExpectEqual(t, test.u32, uint32(127))
ok, err = ConvertString(s, reflect.ValueOf(&test.u64))
ExpectTrue(t, ok)
ExpectNoError(t, err)
ExpectEqual(t, test.u64, uint64(127))
err = Convert(reflect.ValueOf(uint8(64)), field)
ExpectNoError(t, err)
ExpectEqualValues(t, field.Interface(), 64)
})
}
}
type testModel struct {
@@ -150,19 +125,19 @@ func TestConvertor(t *testing.T) {
m := new(testModel)
ExpectNoError(t, MapUnmarshalValidate(map[string]any{"Test": "123"}, m))
ExpectEqual(t, m.Test.foo, 123)
ExpectEqual(t, m.Test.bar, "123")
ExpectEqualValues(t, m.Test.foo, 123)
ExpectEqualValues(t, m.Test.bar, "123")
})
t.Run("int_to_string", func(t *testing.T) {
m := new(testModel)
ExpectNoError(t, MapUnmarshalValidate(map[string]any{"Test": "123"}, m))
ExpectEqual(t, m.Test.foo, 123)
ExpectEqual(t, m.Test.bar, "123")
ExpectEqualValues(t, m.Test.foo, 123)
ExpectEqualValues(t, m.Test.bar, "123")
ExpectNoError(t, MapUnmarshalValidate(map[string]any{"Baz": 123}, m))
ExpectEqual(t, m.Baz, "123")
ExpectNoError(t, MapUnmarshalValidate(map[string]any{"Baz": 456}, m))
ExpectEqualValues(t, m.Baz, "456")
})
t.Run("invalid", func(t *testing.T) {
@@ -177,21 +152,21 @@ func TestStringToSlice(t *testing.T) {
convertible, err := ConvertString("a,b,c", reflect.ValueOf(&dst))
ExpectTrue(t, convertible)
ExpectNoError(t, err)
ExpectEqual(t, dst, []string{"a", "b", "c"})
ExpectEqualValues(t, dst, []string{"a", "b", "c"})
})
t.Run("yaml-like", func(t *testing.T) {
dst := make([]string, 0)
convertible, err := ConvertString("- a\n- b\n- c", reflect.ValueOf(&dst))
ExpectTrue(t, convertible)
ExpectNoError(t, err)
ExpectEqual(t, dst, []string{"a", "b", "c"})
ExpectEqualValues(t, dst, []string{"a", "b", "c"})
})
t.Run("single-line-yaml-like", func(t *testing.T) {
dst := make([]string, 0)
convertible, err := ConvertString("- a", reflect.ValueOf(&dst))
ExpectTrue(t, convertible)
ExpectNoError(t, err)
ExpectEqual(t, dst, []string{"a"})
ExpectEqualValues(t, dst, []string{"a"})
})
}
@@ -215,7 +190,7 @@ func TestStringToMap(t *testing.T) {
convertible, err := ConvertString(" a: b\n c: d", reflect.ValueOf(&dst))
ExpectTrue(t, convertible)
ExpectNoError(t, err)
ExpectEqual(t, dst, map[string]string{"a": "b", "c": "d"})
ExpectEqualValues(t, dst, map[string]string{"a": "b", "c": "d"})
})
}
@@ -234,18 +209,28 @@ func BenchmarkStringToMapYAML(b *testing.B) {
}
func TestStringToStruct(t *testing.T) {
t.Run("yaml-like", func(t *testing.T) {
dst := struct {
A string
B int
}{}
type T struct {
A string
B int
}
t.Run("yaml-like simple", func(t *testing.T) {
var dst T
convertible, err := ConvertString(" A: a\n B: 123", reflect.ValueOf(&dst))
ExpectTrue(t, convertible)
ExpectNoError(t, err)
ExpectEqual(t, dst, struct {
A string
B int
}{"a", 123})
ExpectEqualValues(t, dst.A, "a")
ExpectEqualValues(t, dst.B, 123)
})
type T2 struct {
URL *url.URL
CIDR *net.IPNet
}
t.Run("yaml-like complex", func(t *testing.T) {
var dst T2
convertible, err := ConvertString(" URL: http://example.com\n CIDR: 1.2.3.0/24", reflect.ValueOf(&dst))
ExpectTrue(t, convertible)
ExpectNoError(t, err)
})
}

View File

@@ -4,13 +4,32 @@ import (
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
)
func FormatDuration(d time.Duration) string {
func AppendDuration(d time.Duration, buf []byte) []byte {
if d < 0 {
buf = append(buf, '-')
d = -d
}
if d == 0 {
return append(buf, []byte("0 Seconds")...)
}
switch {
case d < time.Millisecond:
buf = strconv.AppendInt(buf, int64(d.Nanoseconds()), 10)
buf = append(buf, []byte(" ns")...)
return buf
case d < time.Second:
buf = strconv.AppendInt(buf, int64(d.Milliseconds()), 10)
buf = append(buf, []byte(" ms")...)
return buf
}
// Get total seconds from duration
totalSeconds := int64(d.Seconds())
@@ -20,30 +39,27 @@ func FormatDuration(d time.Duration) string {
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60
// Create a slice to hold parts of the duration
var parts []string
if days > 0 {
parts = append(parts, fmt.Sprintf("%d day%s", days, pluralize(days)))
buf = strconv.AppendInt(buf, days, 10)
buf = fmt.Appendf(buf, "day%s, ", Pluralize(days))
}
if hours > 0 {
parts = append(parts, fmt.Sprintf("%d hour%s", hours, pluralize(hours)))
buf = strconv.AppendInt(buf, hours, 10)
buf = fmt.Appendf(buf, "hour%s, ", Pluralize(hours))
}
if minutes > 0 {
parts = append(parts, fmt.Sprintf("%d minute%s", minutes, pluralize(minutes)))
buf = strconv.AppendInt(buf, minutes, 10)
buf = fmt.Appendf(buf, "minute%s, ", Pluralize(minutes))
}
if seconds > 0 && totalSeconds < 3600 {
parts = append(parts, fmt.Sprintf("%d second%s", seconds, pluralize(seconds)))
buf = strconv.AppendInt(buf, seconds, 10)
buf = fmt.Appendf(buf, "second%s, ", Pluralize(seconds))
}
return buf[:len(buf)-2]
}
// Join the parts with appropriate connectors
if len(parts) == 0 {
return "0 Seconds"
}
if len(parts) == 1 {
return parts[0]
}
return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]
func FormatDuration(d time.Duration) string {
return string(AppendDuration(d, nil))
}
func FormatLastSeen(t time.Time) string {
@@ -53,28 +69,93 @@ func FormatLastSeen(t time.Time) string {
return FormatTime(t)
}
func FormatTime(t time.Time) string {
return t.Format("2006-01-02 15:04:05")
func appendRound(f float64, buf []byte) []byte {
return strconv.AppendInt(buf, int64(math.Round(f)), 10)
}
func ParseBool(s string) bool {
switch strings.ToLower(s) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func formatFloat(f float64) string {
func appendFloat(f float64, buf []byte) []byte {
f = math.Round(f*100) / 100
if f == 0 {
return "0"
return buf
}
return strconv.FormatFloat(f, 'f', -1, 64)
return strconv.AppendFloat(buf, f, 'f', -1, 64)
}
func FormatByteSize[T ~int64 | ~uint64 | ~float64](size T) (value, unit string) {
func AppendTime(t time.Time, buf []byte) []byte {
if t.IsZero() {
return append(buf, []byte("never")...)
}
return AppendTimeWithReference(t, time.Now(), buf)
}
func FormatTime(t time.Time) string {
return string(AppendTime(t, nil))
}
func FormatUnixTime(t int64) string {
return FormatTime(time.Unix(t, 0))
}
func FormatTimeWithReference(t, ref time.Time) string {
return string(AppendTimeWithReference(t, ref, nil))
}
func AppendTimeWithReference(t, ref time.Time, buf []byte) []byte {
if t.IsZero() {
return append(buf, []byte("never")...)
}
diff := t.Sub(ref)
absDiff := diff.Abs()
switch {
case absDiff < time.Second:
return append(buf, []byte("now")...)
case absDiff < 3*time.Second:
if diff < 0 {
return append(buf, []byte("just now")...)
}
fallthrough
case absDiff < 60*time.Second:
if diff < 0 {
buf = appendRound(absDiff.Seconds(), buf)
buf = append(buf, []byte(" seconds ago")...)
} else {
buf = append(buf, []byte("in ")...)
buf = appendRound(absDiff.Seconds(), buf)
buf = append(buf, []byte(" seconds")...)
}
return buf
case absDiff < 60*time.Minute:
if diff < 0 {
buf = appendRound(absDiff.Minutes(), buf)
buf = append(buf, []byte(" minutes ago")...)
} else {
buf = append(buf, []byte("in ")...)
buf = appendRound(absDiff.Minutes(), buf)
buf = append(buf, []byte(" minutes")...)
}
return buf
case absDiff < 24*time.Hour:
if diff < 0 {
buf = appendRound(absDiff.Hours(), buf)
buf = append(buf, []byte(" hours ago")...)
} else {
buf = append(buf, []byte("in ")...)
buf = appendRound(absDiff.Hours(), buf)
buf = append(buf, []byte(" hours")...)
}
return buf
case t.Year() == ref.Year():
return t.AppendFormat(buf, "01-02 15:04:05")
default:
return t.AppendFormat(buf, "2006-01-02 15:04:05")
}
}
func FormatByteSize(size int64) string {
return string(AppendByteSize(size, nil))
}
func AppendByteSize[T ~int64 | ~uint64 | ~float64](size T, buf []byte) []byte {
const (
_ = (1 << (10 * iota))
kb
@@ -85,27 +166,32 @@ func FormatByteSize[T ~int64 | ~uint64 | ~float64](size T) (value, unit string)
)
switch {
case size < kb:
return fmt.Sprintf("%v", size), "B"
switch any(size).(type) {
case int64:
buf = strconv.AppendInt(buf, int64(size), 10)
case uint64:
buf = strconv.AppendUint(buf, uint64(size), 10)
case float64:
buf = appendFloat(float64(size), buf)
}
buf = append(buf, []byte(" B")...)
case size < mb:
return formatFloat(float64(size) / kb), "KiB"
buf = appendFloat(float64(size)/kb, buf)
buf = append(buf, []byte(" KiB")...)
case size < gb:
return formatFloat(float64(size) / mb), "MiB"
buf = appendFloat(float64(size)/mb, buf)
buf = append(buf, []byte(" MiB")...)
case size < tb:
return formatFloat(float64(size) / gb), "GiB"
buf = appendFloat(float64(size)/gb, buf)
buf = append(buf, []byte(" GiB")...)
case size < pb:
return formatFloat(float64(size/gb) / kb), "TiB" // prevent overflow
buf = appendFloat(float64(size/gb)/kb, buf)
buf = append(buf, []byte(" TiB")...)
default:
return formatFloat(float64(size/tb) / kb), "PiB" // prevent overflow
buf = appendFloat(float64(size/tb)/kb, buf)
buf = append(buf, []byte(" PiB")...)
}
}
func FormatByteSizeWithUnit[T ~int64 | ~uint64 | ~float64](size T) string {
value, unit := FormatByteSize(size)
return value + " " + unit
}
func PortString(port uint16) string {
return strconv.FormatUint(uint64(port), 10)
return buf
}
func DoYouMean(s string) string {
@@ -115,7 +201,7 @@ func DoYouMean(s string) string {
return "Did you mean " + ansi.HighlightGreen + s + ansi.Reset + "?"
}
func pluralize(n int64) string {
func Pluralize(n int64) string {
if n > 1 {
return "s"
}

View File

@@ -0,0 +1,205 @@
package strutils_test
import (
"testing"
"time"
. "github.com/yusing/go-proxy/internal/utils/strutils"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestFormatTime(t *testing.T) {
now := Must(time.Parse(time.RFC3339, "2021-06-15T12:30:30Z"))
tests := []struct {
name string
time time.Time
expected string
expectedLength int
}{
{
name: "now",
time: now.Add(100 * time.Millisecond),
expected: "now",
},
{
name: "just now (past within 3 seconds)",
time: now.Add(-1 * time.Second),
expected: "just now",
},
{
name: "seconds ago",
time: now.Add(-10 * time.Second),
expected: "10 seconds ago",
},
{
name: "in seconds",
time: now.Add(10 * time.Second),
expected: "in 10 seconds",
},
{
name: "minutes ago",
time: now.Add(-10 * time.Minute),
expected: "10 minutes ago",
},
{
name: "in minutes",
time: now.Add(10 * time.Minute),
expected: "in 10 minutes",
},
{
name: "hours ago",
time: now.Add(-10 * time.Hour),
expected: "10 hours ago",
},
{
name: "in hours",
time: now.Add(10 * time.Hour),
expected: "in 10 hours",
},
{
name: "different day",
time: now.Add(-25 * time.Hour),
expectedLength: len("01-01 15:04:05"),
},
{
name: "same year but different month",
time: now.Add(-30 * 24 * time.Hour),
expectedLength: len("01-01 15:04:05"),
},
{
name: "different year",
time: time.Date(now.Year()-1, 1, 1, 10, 20, 30, 0, now.Location()),
expected: time.Date(now.Year()-1, 1, 1, 10, 20, 30, 0, now.Location()).Format("2006-01-02 15:04:05"),
},
{
name: "zero time",
time: time.Time{},
expected: "never",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatTimeWithReference(tt.time, now)
if tt.expectedLength > 0 {
ExpectEqual(t, len(result), tt.expectedLength, result)
} else {
ExpectEqual(t, result, tt.expected)
}
})
}
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
name string
duration time.Duration
expected string
}{
{
name: "zero duration",
duration: 0,
expected: "0 Seconds",
},
{
name: "seconds only",
duration: 45 * time.Second,
expected: "45 seconds",
},
{
name: "one second",
duration: 1 * time.Second,
expected: "1 second",
},
{
name: "minutes only",
duration: 5 * time.Minute,
expected: "5 minutes",
},
{
name: "one minute",
duration: 1 * time.Minute,
expected: "1 minute",
},
{
name: "hours only",
duration: 3 * time.Hour,
expected: "3 hours",
},
{
name: "one hour",
duration: 1 * time.Hour,
expected: "1 hour",
},
{
name: "days only",
duration: 2 * 24 * time.Hour,
expected: "2 days",
},
{
name: "one day",
duration: 24 * time.Hour,
expected: "1 day",
},
{
name: "complex duration",
duration: 2*24*time.Hour + 3*time.Hour + 45*time.Minute + 15*time.Second,
expected: "2 days, 3 hours and 45 minutes",
},
{
name: "hours and minutes",
duration: 2*time.Hour + 30*time.Minute,
expected: "2 hours and 30 minutes",
},
{
name: "days and hours",
duration: 1*24*time.Hour + 12*time.Hour,
expected: "1 day and 12 hours",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatDuration(tt.duration)
ExpectEqual(t, result, tt.expected)
})
}
}
func TestFormatLastSeen(t *testing.T) {
now := time.Now()
tests := []struct {
name string
time time.Time
expected string
}{
{
name: "zero time",
time: time.Time{},
expected: "never",
},
{
name: "non-zero time",
time: now.Add(-10 * time.Minute),
// The actual result will be handled by FormatTime, which is tested separately
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FormatLastSeen(tt.time)
if tt.name == "zero time" {
ExpectEqual(t, result, tt.expected)
} else {
// Just make sure it's not "never", the actual formatting is tested in TestFormatTime
if result == "never" {
t.Errorf("Expected non-zero time to not return 'never', got %s", result)
}
}
})
}
}

View File

@@ -2,6 +2,7 @@ package strutils
import (
"reflect"
"strconv"
)
type Parser interface {
@@ -24,3 +25,11 @@ func MustParse[T Parser](from string) T {
}
return t
}
func ParseBool(from string) bool {
b, err := strconv.ParseBool(from)
if err != nil {
return false
}
return b
}

View File

@@ -21,50 +21,55 @@ func Must[Result any](r Result, err error) Result {
return r
}
func ExpectNoError(t *testing.T, err error) {
func ExpectNoError(t *testing.T, err error, msgAndArgs ...any) {
t.Helper()
require.NoError(t, err)
require.NoError(t, err, msgAndArgs...)
}
func ExpectHasError(t *testing.T, err error) {
func ExpectHasError(t *testing.T, err error, msgAndArgs ...any) {
t.Helper()
require.Error(t, err)
require.Error(t, err, msgAndArgs...)
}
func ExpectError(t *testing.T, expected error, err error) {
func ExpectError(t *testing.T, expected error, err error, msgAndArgs ...any) {
t.Helper()
require.ErrorIs(t, err, expected)
require.ErrorIs(t, err, expected, msgAndArgs...)
}
func ExpectErrorT[T error](t *testing.T, err error) {
func ExpectErrorT[T error](t *testing.T, err error, msgAndArgs ...any) {
t.Helper()
var errAs T
require.ErrorAs(t, err, &errAs)
require.ErrorAs(t, err, &errAs, msgAndArgs...)
}
func ExpectEqual[T any](t *testing.T, got T, want T) {
func ExpectEqual[T any](t *testing.T, got T, want T, msgAndArgs ...any) {
t.Helper()
require.EqualValues(t, got, want)
require.Equal(t, want, got, msgAndArgs...)
}
func ExpectContains[T any](t *testing.T, got T, wants []T) {
func ExpectEqualValues(t *testing.T, got any, want any, msgAndArgs ...any) {
t.Helper()
require.Contains(t, wants, got)
require.EqualValues(t, want, got, msgAndArgs...)
}
func ExpectTrue(t *testing.T, got bool) {
func ExpectContains[T any](t *testing.T, got T, wants []T, msgAndArgs ...any) {
t.Helper()
require.True(t, got)
require.Contains(t, wants, got, msgAndArgs...)
}
func ExpectFalse(t *testing.T, got bool) {
func ExpectTrue(t *testing.T, got bool, msgAndArgs ...any) {
t.Helper()
require.False(t, got)
require.True(t, got, msgAndArgs...)
}
func ExpectType[T any](t *testing.T, got any) (_ T) {
func ExpectFalse(t *testing.T, got bool, msgAndArgs ...any) {
t.Helper()
require.False(t, got, msgAndArgs...)
}
func ExpectType[T any](t *testing.T, got any, msgAndArgs ...any) (_ T) {
t.Helper()
_, ok := got.(T)
require.True(t, ok)
require.True(t, ok, msgAndArgs...)
return got.(T)
}