Add multi-account usage dashboard
This commit is contained in:
+159
-29
@@ -18,7 +18,16 @@ import (
|
||||
func (a *App) api(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.TrimPrefix(r.URL.Path, "/api/v1/")
|
||||
if p == "system/status" {
|
||||
jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": "0.1.0", "appServer": a.codex.Connected()})
|
||||
connected := false
|
||||
a.mu.RLock()
|
||||
for _, rt := range a.runtimes {
|
||||
if rt.client.Connected() {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": "0.2.0", "appServer": connected})
|
||||
return
|
||||
}
|
||||
if p == "setup" && r.Method == "POST" {
|
||||
@@ -39,13 +48,54 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, map[string]string{"username": username})
|
||||
case p == "auth/logout" && r.Method == "POST":
|
||||
a.logout(w, r)
|
||||
case p == "accounts" && r.Method == "GET":
|
||||
x, e := a.store.Accounts()
|
||||
if e != nil {
|
||||
jsonOut(w, 500, map[string]string{"error": e.Error()})
|
||||
} else {
|
||||
jsonOut(w, 200, x)
|
||||
}
|
||||
case p == "accounts" && r.Method == "POST":
|
||||
var in struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
if decode(r, &in) != nil {
|
||||
jsonOut(w, 400, map[string]string{"error": "请求格式错误"})
|
||||
break
|
||||
}
|
||||
in.DisplayName = strings.TrimSpace(in.DisplayName)
|
||||
if in.DisplayName == "" {
|
||||
in.DisplayName = "新账号"
|
||||
}
|
||||
x, e := a.store.CreateAccount(in.DisplayName)
|
||||
if e == nil {
|
||||
a.addRuntime(x.ID)
|
||||
jsonOut(w, 201, x)
|
||||
} else {
|
||||
jsonOut(w, 500, map[string]string{"error": e.Error()})
|
||||
}
|
||||
case strings.HasPrefix(p, "accounts/"):
|
||||
a.accountAPI(w, r, p)
|
||||
case p == "dashboard":
|
||||
a.mu.RLock()
|
||||
d := a.dash
|
||||
a.mu.RUnlock()
|
||||
jsonOut(w, 200, d)
|
||||
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
|
||||
if id == 0 {
|
||||
id = 1
|
||||
}
|
||||
rt := a.runtime(id)
|
||||
if rt == nil {
|
||||
jsonOut(w, 404, map[string]string{"error": "账号不存在"})
|
||||
} else {
|
||||
rt.syncing.Lock()
|
||||
d := rt.dash
|
||||
rt.syncing.Unlock()
|
||||
jsonOut(w, 200, d)
|
||||
}
|
||||
case p == "sync" && r.Method == "POST":
|
||||
e := a.sync(r.Context())
|
||||
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
|
||||
if id == 0 {
|
||||
id = 1
|
||||
}
|
||||
e := a.syncAccount(r.Context(), id)
|
||||
if e != nil {
|
||||
jsonOut(w, 502, map[string]string{"error": e.Error()})
|
||||
} else {
|
||||
@@ -65,16 +115,6 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
|
||||
code := fmt.Sprintf("%06d", time.Now().UnixNano()%1000000)
|
||||
_ = a.store.SetJSON("telegram_bind", map[string]any{"code": code, "expires": time.Now().Add(10 * time.Minute).Unix()})
|
||||
jsonOut(w, 200, map[string]string{"code": code})
|
||||
case p == "codex/login/device" && r.Method == "POST":
|
||||
a.deviceLogin(w, r)
|
||||
case p == "codex/logout" && r.Method == "POST":
|
||||
var out any
|
||||
e := a.codex.Call(r.Context(), "account/logout", map[string]any{}, &out)
|
||||
if e != nil {
|
||||
jsonOut(w, 502, map[string]string{"error": e.Error()})
|
||||
} else {
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
case p == "maintenance/cleanup" && r.Method == "POST":
|
||||
n, e := a.store.Cleanup(a.general().RetentionDays)
|
||||
if e != nil {
|
||||
@@ -101,6 +141,78 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
|
||||
parts := strings.Split(p, "/")
|
||||
if len(parts) < 2 {
|
||||
jsonOut(w, 404, map[string]string{"error": "接口不存在"})
|
||||
return
|
||||
}
|
||||
id, e := strconv.ParseInt(parts[1], 10, 64)
|
||||
if e != nil {
|
||||
jsonOut(w, 400, map[string]string{"error": "账号 ID 无效"})
|
||||
return
|
||||
}
|
||||
rt := a.runtime(id)
|
||||
if rt == nil {
|
||||
jsonOut(w, 404, map[string]string{"error": "账号不存在"})
|
||||
return
|
||||
}
|
||||
action := ""
|
||||
if len(parts) > 2 {
|
||||
action = parts[2]
|
||||
}
|
||||
switch {
|
||||
case action == "" && r.Method == "PUT":
|
||||
var in struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" {
|
||||
jsonOut(w, 400, map[string]string{"error": "名称不能为空"})
|
||||
return
|
||||
}
|
||||
e = a.store.RenameAccount(id, strings.TrimSpace(in.DisplayName))
|
||||
if e == nil {
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
case action == "" && r.Method == "DELETE":
|
||||
a.mu.Lock()
|
||||
delete(a.runtimes, id)
|
||||
a.mu.Unlock()
|
||||
_ = rt.client.Close()
|
||||
e = a.store.DeleteAccount(id)
|
||||
if e == nil {
|
||||
dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10))
|
||||
if id == 1 {
|
||||
dir = filepath.Join(a.dataDir, "codex")
|
||||
}
|
||||
e = os.RemoveAll(dir)
|
||||
}
|
||||
if e == nil {
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
case action == "login" && len(parts) > 3 && parts[3] == "device" && r.Method == "POST":
|
||||
a.deviceLogin(w, r, id)
|
||||
case action == "logout" && r.Method == "POST":
|
||||
var out any
|
||||
e = rt.client.Call(r.Context(), "account/logout", map[string]any{}, &out)
|
||||
if e == nil {
|
||||
_ = a.store.UpdateAccount(id, nil, nil, false)
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
case action == "sync" && r.Method == "POST":
|
||||
e = a.syncAccount(r.Context(), id)
|
||||
if e == nil {
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
default:
|
||||
jsonOut(w, 404, map[string]string{"error": "接口不存在"})
|
||||
return
|
||||
}
|
||||
if e != nil {
|
||||
jsonOut(w, 502, map[string]string{"error": e.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) setup(w http.ResponseWriter, r *http.Request) {
|
||||
if a.store.Initialized() {
|
||||
jsonOut(w, 409, map[string]string{"error": "系统已初始化"})
|
||||
@@ -211,9 +323,14 @@ func (a *App) generalAPI(w http.ResponseWriter, r *http.Request) {
|
||||
_ = a.store.SetJSON("general", g)
|
||||
jsonOut(w, 200, g)
|
||||
}
|
||||
func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
var out map[string]any
|
||||
e := a.codex.Call(r.Context(), "account/login/start", map[string]any{"type": "chatgptDeviceCode"}, &out)
|
||||
rt := a.runtime(id)
|
||||
if !rt.client.Connected() {
|
||||
jsonOut(w, 503, map[string]string{"error": "该账号服务正在启动,请稍后重试"})
|
||||
return
|
||||
}
|
||||
e := rt.client.Call(r.Context(), "account/login/start", map[string]any{"type": "chatgptDeviceCode"}, &out)
|
||||
if e != nil {
|
||||
jsonOut(w, 502, map[string]string{"error": e.Error()})
|
||||
return
|
||||
@@ -221,8 +338,14 @@ func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, out)
|
||||
}
|
||||
|
||||
func (a *App) sync(ctx context.Context) error {
|
||||
if !a.codex.Connected() {
|
||||
func (a *App) syncAccount(ctx context.Context, id int64) error {
|
||||
rt := a.runtime(id)
|
||||
if rt == nil {
|
||||
return fmt.Errorf("账号不存在")
|
||||
}
|
||||
rt.syncing.Lock()
|
||||
defer rt.syncing.Unlock()
|
||||
if !rt.client.Connected() {
|
||||
return fmt.Errorf("app-server 未连接")
|
||||
}
|
||||
ctx, c := context.WithTimeout(ctx, 20*time.Second)
|
||||
@@ -234,10 +357,18 @@ func (a *App) sync(ctx context.Context) error {
|
||||
PlanType *string `json:"planType"`
|
||||
} `json:"account"`
|
||||
}
|
||||
if e := a.codex.Call(ctx, "account/read", map[string]any{"refreshToken": false}, &ar); e != nil {
|
||||
if e := rt.client.Call(ctx, "account/read", map[string]any{"refreshToken": false}, &ar); e != nil {
|
||||
return e
|
||||
}
|
||||
d := Dashboard{FetchedAt: time.Now().Unix(), Account: AccountView{Connected: ar.Account != nil}, Limits: []LimitBucket{}, Usage: []UsagePoint{}}
|
||||
name := "账号"
|
||||
accounts, _ := a.store.Accounts()
|
||||
for _, x := range accounts {
|
||||
if x.ID == id {
|
||||
name = x.DisplayName
|
||||
break
|
||||
}
|
||||
}
|
||||
d := Dashboard{AccountID: id, DisplayName: name, FetchedAt: time.Now().Unix(), Account: AccountView{Connected: ar.Account != nil}, Limits: []LimitBucket{}, Usage: []UsagePoint{}}
|
||||
if ar.Account != nil {
|
||||
d.Account.Email = ar.Account.Email
|
||||
d.Account.PlanType = ar.Account.PlanType
|
||||
@@ -247,7 +378,7 @@ func (a *App) sync(ctx context.Context) error {
|
||||
RateLimits *rawLimit `json:"rateLimits"`
|
||||
By map[string]rawLimit `json:"rateLimitsByLimitId"`
|
||||
}
|
||||
if e := a.codex.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil {
|
||||
if e := rt.client.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil {
|
||||
if len(lr.By) > 0 {
|
||||
for _, x := range lr.By {
|
||||
d.Limits = append(d.Limits, flattenLimit(x)...)
|
||||
@@ -263,20 +394,19 @@ func (a *App) sync(ctx context.Context) error {
|
||||
Tokens int64 `json:"tokens"`
|
||||
} `json:"dailyUsageBuckets"`
|
||||
}
|
||||
if e := a.codex.Call(ctx, "account/usage/read", map[string]any{}, &ur); e == nil {
|
||||
if e := rt.client.Call(ctx, "account/usage/read", map[string]any{}, &ur); e == nil {
|
||||
d.Summary = ur.Summary
|
||||
for _, x := range ur.Daily {
|
||||
p := UsagePoint{Date: x.StartDate, TotalTokens: x.Tokens}
|
||||
d.Usage = append(d.Usage, p)
|
||||
_, _ = a.store.DB.Exec("INSERT INTO daily_usage(date,total_tokens,fetched_at) VALUES(?,?,?) ON CONFLICT(date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", x.StartDate, x.Tokens, d.FetchedAt)
|
||||
_, _ = a.store.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?) ON CONFLICT(account_id,date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", id, x.StartDate, x.Tokens, d.FetchedAt)
|
||||
}
|
||||
}
|
||||
for _, x := range d.Limits {
|
||||
_, _ = a.store.DB.Exec("INSERT INTO limit_snapshots(limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at) VALUES(?,?,?,?,?,?)", x.LimitID, x.WindowType, x.UsedPercent, x.WindowDurationMinutes, x.ResetsAt, d.FetchedAt)
|
||||
_, _ = a.store.DB.Exec("INSERT INTO limit_snapshots(limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at,account_id) VALUES(?,?,?,?,?,?,?)", x.LimitID, x.WindowType, x.UsedPercent, x.WindowDurationMinutes, x.ResetsAt, d.FetchedAt, id)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.dash = d
|
||||
a.mu.Unlock()
|
||||
rt.dash = d
|
||||
_ = a.store.UpdateAccount(id, d.Account.Email, d.Account.PlanType, d.Account.Connected)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+75
-23
@@ -29,14 +29,18 @@ type App struct {
|
||||
dataDir string
|
||||
store *store.Store
|
||||
vault *security.Vault
|
||||
codex *codex.Client
|
||||
server *http.Server
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
dash Dashboard
|
||||
runtimes map[int64]*accountRuntime
|
||||
loginAttempts sync.Map
|
||||
}
|
||||
type accountRuntime struct {
|
||||
client *codex.Client
|
||||
dash Dashboard
|
||||
syncing sync.Mutex
|
||||
}
|
||||
|
||||
func New() (*App, error) {
|
||||
dir := env("DATA_DIR", "/data")
|
||||
@@ -49,8 +53,11 @@ func New() (*App, error) {
|
||||
return nil, e
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a := &App{dataDir: dir, store: s, vault: v, ctx: ctx, cancel: cancel, dash: Dashboard{Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true}}
|
||||
a.codex = codex.New(filepath.Join(dir, "codex"), a.onCodexNotification)
|
||||
a := &App{dataDir: dir, store: s, vault: v, ctx: ctx, cancel: cancel, runtimes: map[int64]*accountRuntime{}}
|
||||
accounts, _ := s.Accounts()
|
||||
for _, account := range accounts {
|
||||
a.addRuntime(account.ID)
|
||||
}
|
||||
a.server = &http.Server{Addr: env("LISTEN_ADDR", ":8080"), Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
|
||||
return a, nil
|
||||
}
|
||||
@@ -79,45 +86,81 @@ func (a *App) Close() {
|
||||
ctx, c := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer c()
|
||||
_ = a.server.Shutdown(ctx)
|
||||
_ = a.codex.Close()
|
||||
a.mu.RLock()
|
||||
for _, rt := range a.runtimes {
|
||||
_ = rt.client.Close()
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
_ = a.store.DB.Close()
|
||||
}
|
||||
func (a *App) keepCodex() {
|
||||
delay := time.Second
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
if !a.codex.Connected() {
|
||||
if e := a.codex.Start(a.ctx); e == nil {
|
||||
a.mu.RLock()
|
||||
ids := make([]int64, 0, len(a.runtimes))
|
||||
for id := range a.runtimes {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
for _, id := range ids {
|
||||
rt := a.runtime(id)
|
||||
if rt == nil || rt.client.Connected() {
|
||||
continue
|
||||
}
|
||||
if e := rt.client.Start(a.ctx); e == nil {
|
||||
ctx, c := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
e = a.codex.Initialize(ctx)
|
||||
e = rt.client.Initialize(ctx)
|
||||
c()
|
||||
if e == nil {
|
||||
delay = time.Second
|
||||
_ = a.sync(context.Background())
|
||||
_ = a.syncAccount(context.Background(), id)
|
||||
} else {
|
||||
log.Printf("app-server initialize: %v", e)
|
||||
// A live process is not necessarily an initialized process. Tear it
|
||||
// down so the next iteration starts a fresh protocol session.
|
||||
_ = a.codex.Close()
|
||||
}
|
||||
}
|
||||
if !a.codex.Connected() {
|
||||
time.Sleep(delay)
|
||||
if delay < 30*time.Second {
|
||||
delay *= 2
|
||||
_ = rt.client.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
func (a *App) onCodexNotification(method string, _ json.RawMessage) {
|
||||
if method == "account/updated" || method == "account/rateLimits/updated" {
|
||||
go a.sync(context.Background())
|
||||
func (a *App) onCodexNotification(id int64) func(string, json.RawMessage) {
|
||||
return func(method string, _ json.RawMessage) {
|
||||
if method == "account/updated" || method == "account/rateLimits/updated" {
|
||||
go a.syncAccount(context.Background(), id)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (a *App) addRuntime(id int64) {
|
||||
dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10), "codex")
|
||||
if id == 1 {
|
||||
// Account 1 deliberately keeps the legacy path so upgrades retain the
|
||||
// existing login, and fresh installs use the same deterministic path.
|
||||
dir = filepath.Join(a.dataDir, "codex")
|
||||
}
|
||||
rt := &accountRuntime{client: codex.New(dir, a.onCodexNotification(id)), dash: Dashboard{Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true}}
|
||||
a.mu.Lock()
|
||||
a.runtimes[id] = rt
|
||||
a.mu.Unlock()
|
||||
}
|
||||
func (a *App) runtime(id int64) *accountRuntime {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.runtimes[id]
|
||||
}
|
||||
func (a *App) syncAll(ctx context.Context) {
|
||||
a.mu.RLock()
|
||||
ids := make([]int64, 0, len(a.runtimes))
|
||||
for id := range a.runtimes {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
for _, id := range ids {
|
||||
_ = a.syncAccount(ctx, id)
|
||||
}
|
||||
}
|
||||
func (a *App) scheduler() {
|
||||
@@ -130,7 +173,7 @@ func (a *App) scheduler() {
|
||||
case <-t.C:
|
||||
g := a.general()
|
||||
if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 {
|
||||
_ = a.sync(context.Background())
|
||||
a.syncAll(context.Background())
|
||||
}
|
||||
_, _ = a.store.Cleanup(g.RetentionDays)
|
||||
go a.processReminders()
|
||||
@@ -146,7 +189,16 @@ func (a *App) routes() http.Handler {
|
||||
jsonOut(w, 503, map[string]string{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]any{"status": "ok", "appServer": a.codex.Connected()})
|
||||
connected := false
|
||||
a.mu.RLock()
|
||||
for _, rt := range a.runtimes {
|
||||
if rt.client.Connected() {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
jsonOut(w, 200, map[string]any{"status": "ok", "appServer": connected})
|
||||
})
|
||||
m.HandleFunc("/api/v1/", a.api)
|
||||
sub, _ := fs.Sub(webassets.Assets, "dist")
|
||||
|
||||
@@ -291,30 +291,45 @@ func (a *App) handleTG(t TelegramSettings, chat int64, text string) {
|
||||
return
|
||||
}
|
||||
if text == "立即刷新" || text == "/refresh" {
|
||||
_ = a.sync(context.Background())
|
||||
a.syncAll(context.Background())
|
||||
}
|
||||
a.mu.RLock()
|
||||
d := a.dash
|
||||
a.mu.RUnlock()
|
||||
msg := "Codex 用量\n"
|
||||
a.mu.RLock()
|
||||
ds := make([]Dashboard, 0, len(a.runtimes))
|
||||
for _, rt := range a.runtimes {
|
||||
rt.syncing.Lock()
|
||||
ds = append(ds, rt.dash)
|
||||
rt.syncing.Unlock()
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
switch text {
|
||||
case "重置时间", "/reset":
|
||||
for _, x := range d.Limits {
|
||||
msg += fmt.Sprintf("%s/%s:%s\n", x.LimitID, x.WindowType, time.Unix(x.ResetsAt, 0).Format(time.RFC3339))
|
||||
for _, d := range ds {
|
||||
msg += "[" + d.DisplayName + "]\n"
|
||||
for _, x := range d.Limits {
|
||||
msg += fmt.Sprintf("%s/%s:%s\n", x.LimitID, x.WindowType, time.Unix(x.ResetsAt, 0).Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
case "账户信息", "/account":
|
||||
msg += fmt.Sprintf("连接:%v\n", d.Account.Connected)
|
||||
if d.Account.Email != nil {
|
||||
msg += "账户:" + *d.Account.Email + "\n"
|
||||
}
|
||||
if d.Account.PlanType != nil {
|
||||
msg += "套餐:" + *d.Account.PlanType
|
||||
for _, d := range ds {
|
||||
msg += fmt.Sprintf("[%s] 连接:%v\n", d.DisplayName, d.Account.Connected)
|
||||
if d.Account.Email != nil {
|
||||
msg += "账户:" + *d.Account.Email + "\n"
|
||||
}
|
||||
if d.Account.PlanType != nil {
|
||||
msg += "套餐:" + *d.Account.PlanType + "\n"
|
||||
}
|
||||
}
|
||||
case "历史概览", "/usage":
|
||||
msg += fmt.Sprintf("Lifetime tokens:%s\n历史天数:%d", num(d.Summary.LifetimeTokens), len(d.Usage))
|
||||
for _, d := range ds {
|
||||
msg += fmt.Sprintf("[%s] Lifetime tokens:%s,历史天数:%d\n", d.DisplayName, num(d.Summary.LifetimeTokens), len(d.Usage))
|
||||
}
|
||||
default:
|
||||
for _, x := range d.Limits {
|
||||
msg += fmt.Sprintf("%s/%s:%.1f%%,重置 %s\n", x.LimitID, x.WindowType, x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339))
|
||||
for _, d := range ds {
|
||||
msg += "[" + d.DisplayName + "]\n"
|
||||
for _, x := range d.Limits {
|
||||
msg += fmt.Sprintf("%s/%s:剩余 %.1f%%,重置 %s\n", x.LimitID, x.WindowType, 100-x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = tgSend(t, msg)
|
||||
@@ -328,57 +343,64 @@ func num(n *int64) string {
|
||||
func (a *App) processReminders() {
|
||||
g := a.general()
|
||||
a.mu.RLock()
|
||||
d := a.dash
|
||||
ds := make([]Dashboard, 0, len(a.runtimes))
|
||||
for _, rt := range a.runtimes {
|
||||
rt.syncing.Lock()
|
||||
ds = append(ds, rt.dash)
|
||||
rt.syncing.Unlock()
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
now := time.Now()
|
||||
for _, x := range d.Limits {
|
||||
for _, kind := range []string{"before", "after"} {
|
||||
if kind == "before" && !g.NotifyBefore {
|
||||
continue
|
||||
}
|
||||
if kind == "after" && !g.NotifyAfter {
|
||||
continue
|
||||
}
|
||||
at := time.Unix(x.ResetsAt, 0)
|
||||
if kind == "before" {
|
||||
at = at.Add(-time.Duration(g.BeforeMinutes) * time.Minute)
|
||||
}
|
||||
if now.Before(at) || now.Sub(at) > 6*time.Hour {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s:%s:%d:%s", x.LimitID, x.WindowType, x.ResetsAt, kind)
|
||||
var exists int
|
||||
if a.store.DB.QueryRow("SELECT 1 FROM notifications WHERE dedupe_key=? AND status='sent'", key).Scan(&exists) == nil {
|
||||
continue
|
||||
}
|
||||
body := fmt.Sprintf("Codex %s/%s 当前用量 %.1f%%,重置时间 %s。", x.LimitID, x.WindowType, x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339))
|
||||
ok := true
|
||||
errs := []string{}
|
||||
if t, e := a.telegramSecret(); e == nil && t.Enabled && t.ChatID != 0 {
|
||||
if e = tgSend(t, body); e != nil {
|
||||
ok = false
|
||||
errs = append(errs, e.Error())
|
||||
for _, d := range ds {
|
||||
for _, x := range d.Limits {
|
||||
for _, kind := range []string{"before", "after"} {
|
||||
if kind == "before" && !g.NotifyBefore {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if s, e := a.smtpSecret(); e == nil && s.Enabled {
|
||||
if e = sendSMTP(s, "Codex 用量重置提醒", body); e != nil {
|
||||
ok = false
|
||||
errs = append(errs, e.Error())
|
||||
if kind == "after" && !g.NotifyAfter {
|
||||
continue
|
||||
}
|
||||
}
|
||||
status := "sent"
|
||||
var sent any = time.Now().Unix()
|
||||
if !ok {
|
||||
status = "failed"
|
||||
sent = nil
|
||||
}
|
||||
_, _ = a.store.DB.Exec(`INSERT INTO notifications(dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at)
|
||||
at := time.Unix(x.ResetsAt, 0)
|
||||
if kind == "before" {
|
||||
at = at.Add(-time.Duration(g.BeforeMinutes) * time.Minute)
|
||||
}
|
||||
if now.Before(at) || now.Sub(at) > 6*time.Hour {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%d:%s:%s:%d:%s", d.AccountID, x.LimitID, x.WindowType, x.ResetsAt, kind)
|
||||
var exists int
|
||||
if a.store.DB.QueryRow("SELECT 1 FROM notifications WHERE dedupe_key=? AND status='sent'", key).Scan(&exists) == nil {
|
||||
continue
|
||||
}
|
||||
body := fmt.Sprintf("Codex [%s] %s/%s 剩余 %.1f%%,重置时间 %s。", d.DisplayName, x.LimitID, x.WindowType, 100-x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339))
|
||||
ok := true
|
||||
errs := []string{}
|
||||
if t, e := a.telegramSecret(); e == nil && t.Enabled && t.ChatID != 0 {
|
||||
if e = tgSend(t, body); e != nil {
|
||||
ok = false
|
||||
errs = append(errs, e.Error())
|
||||
}
|
||||
}
|
||||
if s, e := a.smtpSecret(); e == nil && s.Enabled {
|
||||
if e = sendSMTP(s, "Codex 用量重置提醒", body); e != nil {
|
||||
ok = false
|
||||
errs = append(errs, e.Error())
|
||||
}
|
||||
}
|
||||
status := "sent"
|
||||
var sent any = time.Now().Unix()
|
||||
if !ok {
|
||||
status = "failed"
|
||||
sent = nil
|
||||
}
|
||||
_, _ = a.store.DB.Exec(`INSERT INTO notifications(dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(dedupe_key) DO UPDATE SET
|
||||
status=excluded.status,
|
||||
attempts=notifications.attempts+1,
|
||||
last_error=excluded.last_error,
|
||||
sent_at=excluded.sent_at`, key, "configured", kind, status, 1, strings.Join(errs, "; "), at.Unix(), sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +67,15 @@ type UsagePoint struct {
|
||||
OutputTokens *int64 `json:"outputTokens"`
|
||||
}
|
||||
type Dashboard struct {
|
||||
Account AccountView `json:"account"`
|
||||
Limits []LimitBucket `json:"limits"`
|
||||
Summary UsageSummary `json:"summary"`
|
||||
Usage []UsagePoint `json:"usage"`
|
||||
FetchedAt int64 `json:"fetchedAt"`
|
||||
Stale bool `json:"stale"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
AccountID int64 `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Account AccountView `json:"account"`
|
||||
Limits []LimitBucket `json:"limits"`
|
||||
Summary UsageSummary `json:"summary"`
|
||||
Usage []UsagePoint `json:"usage"`
|
||||
FetchedAt int64 `json:"fetchedAt"`
|
||||
Stale bool `json:"stale"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
func defaults() GeneralSettings {
|
||||
|
||||
@@ -42,7 +42,138 @@ CREATE TABLE IF NOT EXISTS notifications (dedupe_key TEXT PRIMARY KEY, channel T
|
||||
CREATE TABLE IF NOT EXISTS telegram_updates (id INTEGER PRIMARY KEY CHECK(id=1), offset INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT OR IGNORE INTO telegram_updates(id,offset) VALUES(1,0);
|
||||
`)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateAccounts()
|
||||
}
|
||||
|
||||
func (s *Store) migrateAccounts() error {
|
||||
tx, err := s.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.Exec(`CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
plan_type TEXT,
|
||||
connected INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int
|
||||
if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if _, err = tx.Exec("INSERT INTO accounts(id,display_name,created_at,updated_at) VALUES(1,'默认账号',?,?)", time.Now().Unix(), time.Now().Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"daily_usage", "limit_snapshots"} {
|
||||
var found int
|
||||
rows, qerr := tx.Query("PRAGMA table_info(" + table + ")")
|
||||
if qerr != nil {
|
||||
return qerr
|
||||
}
|
||||
for rows.Next() {
|
||||
var cid, notnull, pk int
|
||||
var name, typ string
|
||||
var def any
|
||||
_ = rows.Scan(&cid, &name, &typ, ¬null, &def, &pk)
|
||||
if name == "account_id" {
|
||||
found = 1
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if found == 0 {
|
||||
if table == "daily_usage" {
|
||||
_, err = tx.Exec(`ALTER TABLE daily_usage RENAME TO daily_usage_legacy;
|
||||
CREATE TABLE daily_usage (account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,date TEXT NOT NULL,total_tokens INTEGER NOT NULL,fetched_at INTEGER NOT NULL,PRIMARY KEY(account_id,date));
|
||||
INSERT INTO daily_usage SELECT 1,date,total_tokens,fetched_at FROM daily_usage_legacy;
|
||||
DROP TABLE daily_usage_legacy;`)
|
||||
} else {
|
||||
_, err = tx.Exec(`ALTER TABLE limit_snapshots RENAME TO limit_snapshots_legacy;
|
||||
CREATE TABLE limit_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
limit_id TEXT NOT NULL,
|
||||
window_type TEXT NOT NULL,
|
||||
used_percent REAL NOT NULL,
|
||||
duration_mins INTEGER NOT NULL,
|
||||
resets_at INTEGER NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO limit_snapshots(id,account_id,limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at)
|
||||
SELECT id,1,limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at FROM limit_snapshots_legacy;
|
||||
DROP TABLE limit_snapshots_legacy;
|
||||
CREATE INDEX idx_limits_time ON limit_snapshots(fetched_at);`)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
ID int64 `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email *string `json:"email"`
|
||||
PlanType *string `json:"planType"`
|
||||
Connected bool `json:"connected"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (s *Store) Accounts() ([]Account, error) {
|
||||
rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,connected,created_at,updated_at FROM accounts ORDER BY id")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Account{}
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (s *Store) CreateAccount(name string) (Account, error) {
|
||||
now := time.Now().Unix()
|
||||
r, e := s.DB.Exec("INSERT INTO accounts(display_name,created_at,updated_at) VALUES(?,?,?)", name, now, now)
|
||||
if e != nil {
|
||||
return Account{}, e
|
||||
}
|
||||
id, _ := r.LastInsertId()
|
||||
return Account{ID: id, DisplayName: name, CreatedAt: now, UpdatedAt: now}, nil
|
||||
}
|
||||
func (s *Store) RenameAccount(id int64, name string) error {
|
||||
r, e := s.DB.Exec("UPDATE accounts SET display_name=?,updated_at=? WHERE id=?", name, time.Now().Unix(), id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
n, _ := r.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Store) UpdateAccount(id int64, email, plan *string, connected bool) error {
|
||||
_, e := s.DB.Exec("UPDATE accounts SET email=?,plan_type=?,connected=?,updated_at=? WHERE id=?", email, plan, connected, time.Now().Unix(), id)
|
||||
return e
|
||||
}
|
||||
func (s *Store) DeleteAccount(id int64) error {
|
||||
_, e := s.DB.Exec("DELETE FROM accounts WHERE id=?", id)
|
||||
return e
|
||||
}
|
||||
|
||||
func (s *Store) Get(key string) (string, bool) {
|
||||
|
||||
@@ -26,6 +26,104 @@ func TestSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountsAndPerAccountUsage(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.DB.Close()
|
||||
accounts, err := s.Accounts()
|
||||
if err != nil || len(accounts) != 1 || accounts[0].ID != 1 {
|
||||
t.Fatalf("default accounts = %#v, %v", accounts, err)
|
||||
}
|
||||
second, err := s.CreateAccount("Team workspace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []int64{1, second.ID} {
|
||||
if _, err = s.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?)", id, "2026-08-13", id*100, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = s.DeleteAccount(second.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err = s.DB.QueryRow("SELECT COUNT(*) FROM daily_usage WHERE account_id=?", second.ID).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("usage was not cascaded: %d, %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec("CREATE TABLE daily_usage(date TEXT PRIMARY KEY,total_tokens INTEGER NOT NULL,fetched_at INTEGER NOT NULL); INSERT INTO daily_usage VALUES('2026-08-12',321,1)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
s, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.DB.Close()
|
||||
var accountID, tokens int64
|
||||
if err = s.DB.QueryRow("SELECT account_id,total_tokens FROM daily_usage WHERE date='2026-08-12'").Scan(&accountID, &tokens); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accountID != 1 || tokens != 321 {
|
||||
t.Fatalf("migrated row = account %d, tokens %d", accountID, tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopulatedLegacyLimitSnapshotsMigrateToDefaultAccount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE limit_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
limit_id TEXT NOT NULL,
|
||||
window_type TEXT NOT NULL,
|
||||
used_percent REAL NOT NULL,
|
||||
duration_mins INTEGER NOT NULL,
|
||||
resets_at INTEGER NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_limits_time ON limit_snapshots(fetched_at);
|
||||
INSERT INTO limit_snapshots(id,limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at)
|
||||
VALUES(7,'codex','primary',42.5,300,1700000000,1699990000);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
s, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.DB.Close()
|
||||
var id, accountID, duration int64
|
||||
var limitID, window string
|
||||
var used float64
|
||||
err = s.DB.QueryRow("SELECT id,account_id,limit_id,window_type,used_percent,duration_mins FROM limit_snapshots").Scan(&id, &accountID, &limitID, &window, &used, &duration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != 7 || accountID != 1 || limitID != "codex" || window != "primary" || used != 42.5 || duration != 300 {
|
||||
t.Fatalf("migrated limit = id %d, account %d, %s/%s, %.1f, duration %d", id, accountID, limitID, window, used, duration)
|
||||
}
|
||||
var indexCount int
|
||||
if err = s.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_limits_time'").Scan(&indexCount); err != nil || indexCount != 1 {
|
||||
t.Fatalf("limit index was not recreated: %d, %v", indexCount, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupIncludesCommittedWALData(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Open(dir)
|
||||
|
||||
Vendored
+2
-2
@@ -1,3 +1,3 @@
|
||||
<script type="module" crossorigin src="/assets/index-Bcayo4C9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D4ZT533H.css">
|
||||
<script type="module" crossorigin src="/assets/index-CCkNuko2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-2I4Pedo2.css">
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user