Skip to content

Commit 3d746ac

Browse files
committed
chore: checkpoint admin webui work before Jude v19 redesign
Snapshot the in-flight webui theme, i18n/error-copy libs, config cleanup and their Go/JS tests so the design-system migration lands as its own reviewable diff.
1 parent 0ad66ba commit 3d746ac

314 files changed

Lines changed: 4379 additions & 1019 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,19 @@ jobs:
4141
cache-dependency-path: webui/package-lock.json
4242
- run: npm ci
4343
working-directory: webui
44+
- run: npm run api:types
45+
working-directory: webui
46+
- run: git diff --exit-code -- src/lib/generated-api.ts
47+
working-directory: webui
48+
- run: npm test
49+
working-directory: webui
4450
- run: npm run typecheck
4551
working-directory: webui
4652
- run: npm run build
4753
working-directory: webui
54+
- run: npm run check:embedded-assets
55+
working-directory: webui
56+
- run: go build ./cmd/auto-router
4857

4958
race:
5059
runs-on: ubuntu-latest

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@
1414
*.exe
1515
*.test
1616
coverage.out
17+
/webui/.generated/

‎Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ RUN npm ci
77
WORKDIR /src
88
COPY docs/openapi.yaml ./docs/openapi.yaml
99
COPY webui ./webui
10-
RUN cd webui && npm run api:types && npm run typecheck && npm run build
10+
RUN cd webui && npm run api:types && npm run typecheck && npm run build && npm run check:embedded-assets
1111

1212
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS go-build
1313
ARG TARGETOS

‎README.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,24 @@ curl http://127.0.0.1:8080/v1/chat/completions \
7070

7171
Anthropic 和 Gemini 原生接口要求所选上游支持相同协议。OpenAI Chat 文本请求可转换后转发至 Anthropic 或 Gemini,但只支持有限的安全子集;不支持的参数或功能会在发送上游前被拒绝。
7272

73-
管理界面还提供提供商与模型管理、模型组配置、推理密钥管理、请求日志和用量统计。服务状态可通过 `GET /healthz` 与 `GET /readyz` 检查。完整接口定义见 [OpenAPI 文档](docs/openapi.yaml)。
73+
管理界面提供提供商与模型管理、模型组配置、推理密钥管理、请求日志和用量统计。系统设置页可配置运行时路由偏好、策略等级、allow/deny 列表、持久日志、Jev 输入模式、会话 TTL 与 models.dev 同步范围;每个区域只提交对应的设置 patch。偏好是无请求头时的全局默认值,请求方可通过 `X-Routing-Preference` 覆盖。质量偏好按已声明能力评分;成本和延迟等级是管理员维护的相对序位,不是实测基准、账单价格或实测延迟。
74+
75+
模型管理页可查看 models.dev 最近同步结果并显式触发全局同步;仪表盘分开显示服务健康/依赖状态与路由、Jev 窗口汇总。设置页的“重置所有运行时覆盖”会恢复代码默认值并清除运行时保存的 Jev 密钥,但不会删除 Provider、模型、绑定或入站凭据。
76+
77+
`/debug/analyze` 与 `/debug/route` 是可选的离线诊断端点,服务端仍会执行 loopback/配置限制;它们可能处理敏感请求内容,不通过远程管理页面提供交互。
78+
79+
**旧配置迁移:** 服务启动不再读取旧 JSON 配置文件或 `AUTO_ROUTER_*` 运行配置环境变量;运行配置以代码默认值启动,再应用 SQLite 中保存的运行时覆盖。旧值不会自动迁移,请在管理台的“系统设置”中重新录入需要保留的运行时设置。传入旧版 `-config` 只会显示忽略提示;旧环境变量存在时会显示通用迁移提示,不会记录或回显变量名和值。`-sync-models` 仅显示迁移提示并立即退出,不会执行同步;请使用管理台的“模型管理 → 立即同步 models.dev”。Compose 示例中的 `AUTO_ROUTER_VERSION`、`AUTO_ROUTER_BIND` 与 `AUTO_ROUTER_PORT` 是 Compose 文件插值项,不会作为服务运行配置传入容器。
80+
81+
服务状态也可通过 `GET /healthz` 与 `GET /readyz` 检查。完整接口定义见 [OpenAPI 文档](docs/openapi.yaml)。
82+
83+
## 发布前验证
84+
85+
CI 运行 Go vet/test/race/build 与 WebUI 的 OpenAPI 类型生成一致性检查、Vitest、类型检查和生产构建;Playwright E2E 不在 CI 中运行,发布前需本地执行并检查失败 trace:
86+
87+
```sh
88+
cd webui
89+
npm ci
90+
npm run test:e2e
91+
```
92+
93+
默认发布端口保持 loopback 绑定;公开访问应通过 TLS 反向代理。

‎cmd/auto-router/main.go‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"net/http"
1313
"os"
1414
"os/signal"
15+
"strings"
1516
"sync/atomic"
1617
"syscall"
1718
"time"
@@ -71,6 +72,20 @@ func main() {
7172
func notifyContext(parent context.Context) (context.Context, context.CancelFunc) {
7273
return signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
7374
}
75+
76+
// hasLegacyAutoRouterEnvironment detects the former environment-based settings
77+
// without retaining or disclosing either names or values in the warning.
78+
func hasLegacyAutoRouterEnvironment(environment []string) bool {
79+
const prefix = "AUTO_ROUTER_"
80+
for _, entry := range environment {
81+
name, _, ok := strings.Cut(entry, "=")
82+
if ok && len(name) > len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) {
83+
return true
84+
}
85+
}
86+
return false
87+
}
88+
7489
func run(ctx context.Context, args []string, output io.Writer) error {
7590
flags := flag.NewFlagSet("auto-router", flag.ContinueOnError)
7691
flags.SetOutput(output)
@@ -95,6 +110,16 @@ func run(ctx context.Context, args []string, output io.Writer) error {
95110
if flags.NArg() != 0 {
96111
return errors.New("unexpected positional arguments; use -help for usage")
97112
}
113+
if hasLegacyAutoRouterEnvironment(os.Environ()) {
114+
fmt.Fprintln(output, "warning: legacy AUTO_ROUTER_* environment variables are ignored; configure runtime settings in the authenticated management UI")
115+
}
116+
if *configPath != "" {
117+
fmt.Fprintln(output, "warning: -config is deprecated and ignored; configuration files and AUTO_ROUTER_* variables no longer affect runtime behavior")
118+
}
119+
if *syncModels {
120+
fmt.Fprintln(output, "warning: -sync-models is deprecated and ignored; synchronize models.dev from the management UI (Admin → Models → Sync)")
121+
return nil
122+
}
98123
if *jevCheckPrompt != "" && !*jevCheck {
99124
return errors.New("-jev-check-prompt requires -jev-check")
100125
}
@@ -105,7 +130,6 @@ func run(ctx context.Context, args []string, output io.Writer) error {
105130
name string
106131
enabled bool
107132
}{
108-
{"-sync-models", *syncModels},
109133
{"-recover-admin", *recoverAdmin},
110134
{"-jev-check", *jevCheck},
111135
{"-analyze-check", analyzeCheck.enabled},
@@ -128,17 +152,11 @@ func run(ctx context.Context, args []string, output io.Writer) error {
128152
if err := logsCheck.validate(flagsSet(flags)); err != nil {
129153
return err
130154
}
131-
if *configPath != "" {
132-
fmt.Fprintln(output, "warning: -config is deprecated and ignored; configuration files and AUTO_ROUTER_* variables no longer affect runtime behavior")
133-
}
134155
cfg := config.Defaults()
135156
if *listenAddress != "" {
136157
cfg.HTTP.Address = *listenAddress
137158
}
138159
sources := config.Sources{}
139-
if *syncModels {
140-
return errors.New("model synchronization is managed from the WebUI (Admin → Models → Sync); -sync-models did not run")
141-
}
142160
if *recoverAdmin {
143161
return runAdminRecovery(ctx, cfg, output)
144162
}

‎cmd/auto-router/main_test.go‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestLegacyStartupOptionsOnlyWarnAndDoNotRunOldOperations(t *testing.T) {
13+
configPath := filepath.Join(t.TempDir(), "legacy-config.json")
14+
if err := os.WriteFile(configPath, []byte(`not-json`), 0o600); err != nil {
15+
t.Fatal(err)
16+
}
17+
const secret = "do-not-print-this-value"
18+
t.Setenv("AUTO_ROUTER_LEGACY_TEST_SECRET", secret)
19+
20+
var output bytes.Buffer
21+
err := run(context.Background(), []string{"-config", configPath, "-sync-models"}, &output)
22+
if err != nil {
23+
t.Fatalf("legacy flags should exit after migration warnings, got: %v", err)
24+
}
25+
text := output.String()
26+
for _, expected := range []string{
27+
"legacy AUTO_ROUTER_* environment variables are ignored",
28+
"-config is deprecated and ignored",
29+
"-sync-models is deprecated and ignored",
30+
"Admin → Models → Sync",
31+
} {
32+
if !strings.Contains(text, expected) {
33+
t.Errorf("warning output %q does not contain %q", text, expected)
34+
}
35+
}
36+
for _, forbidden := range []string{configPath, "AUTO_ROUTER_LEGACY_TEST_SECRET", secret, "not-json"} {
37+
if strings.Contains(text, forbidden) {
38+
t.Errorf("warning output disclosed %q: %s", forbidden, text)
39+
}
40+
}
41+
}
42+
43+
func TestHasLegacyAutoRouterEnvironmentUsesNamesOnly(t *testing.T) {
44+
if !hasLegacyAutoRouterEnvironment([]string{"AUTO_ROUTER_ROUTING_DEFAULT_PREFERENCE=cost"}) {
45+
t.Fatal("expected legacy variable to be detected")
46+
}
47+
if !hasLegacyAutoRouterEnvironment([]string{"auto_router_legacy=secret"}) {
48+
t.Fatal("expected case-insensitive legacy prefix to be detected")
49+
}
50+
for _, environment := range [][]string{
51+
{"HTTP_PROXY=http://example.invalid"},
52+
{"AUTO_ROUTER_=empty-suffix"},
53+
{"AUTO_ROUTERISH_OPTION=value"},
54+
} {
55+
if hasLegacyAutoRouterEnvironment(environment) {
56+
t.Errorf("unexpected legacy environment detection for %q", environment)
57+
}
58+
}
59+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"io"
7+
"log/slog"
8+
"net/http"
9+
"net/http/httptest"
10+
"testing"
11+
"time"
12+
13+
"github.com/nextroad-dev/Auto-Router/internal/config"
14+
"github.com/nextroad-dev/Auto-Router/internal/models"
15+
"github.com/nextroad-dev/Auto-Router/internal/storage"
16+
)
17+
18+
func newRegistrySyncTestDB(t *testing.T) *sql.DB {
19+
t.Helper()
20+
ctx := context.Background()
21+
db, err := storage.Open(ctx, storage.Options{Path: ":memory:", BusyTimeout: time.Second})
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
t.Cleanup(func() { _ = db.Close() })
26+
if err := storage.Migrate(ctx, db); err != nil {
27+
t.Fatal(err)
28+
}
29+
return db
30+
}
31+
32+
func registrySyncConfig(url string, include []string) config.Config {
33+
cfg := config.Defaults()
34+
cfg.Registry.Sync.URL = url
35+
cfg.Registry.Sync.Timeout = config.Duration(time.Second)
36+
cfg.Registry.Sync.Include = include
37+
return cfg
38+
}
39+
40+
func TestRegistrySyncRejectsEmptyAllowlistWithoutNetworkRequest(t *testing.T) {
41+
requests := 0
42+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
43+
requests++
44+
w.WriteHeader(http.StatusOK)
45+
}))
46+
defer server.Close()
47+
48+
db := newRegistrySyncTestDB(t)
49+
err := syncRegistry(context.Background(), registrySyncConfig(server.URL, nil), db, slog.New(slog.NewTextHandler(io.Discard, nil)))
50+
if err == nil {
51+
t.Fatal("empty allowlist unexpectedly synchronized")
52+
}
53+
if requests != 0 {
54+
t.Fatalf("empty allowlist made %d upstream requests", requests)
55+
}
56+
}
57+
58+
func TestRegistrySyncFailureLeavesExistingCatalogAndStateUntouched(t *testing.T) {
59+
ctx := context.Background()
60+
db := newRegistrySyncTestDB(t)
61+
if err := storage.CreateProvider(ctx, db, storage.NewProvider{Key: "existing-provider", Enabled: true}); err != nil {
62+
t.Fatal(err)
63+
}
64+
if err := storage.CreateModel(ctx, db, storage.NewModel{ID: "existing-model", Enabled: true}); err != nil {
65+
t.Fatal(err)
66+
}
67+
68+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
69+
http.Error(w, "upstream unavailable", http.StatusBadGateway)
70+
}))
71+
defer server.Close()
72+
73+
err := syncRegistry(context.Background(), registrySyncConfig(server.URL, []string{"openai/gpt-test"}), db, slog.New(slog.NewTextHandler(io.Discard, nil)))
74+
if err == nil {
75+
t.Fatal("upstream failure unexpectedly reported success")
76+
}
77+
if _, found, err := storage.GetProvider(ctx, db, "existing-provider"); err != nil || !found {
78+
t.Fatalf("failed sync changed existing provider: found=%t err=%v", found, err)
79+
}
80+
if _, found, err := storage.GetModel(ctx, db, "existing-model"); err != nil || !found {
81+
t.Fatalf("failed sync changed existing model: found=%t err=%v", found, err)
82+
}
83+
if _, found, err := storage.LoadSyncState(ctx, db, models.SourceModelsDev); err != nil || found {
84+
t.Fatalf("failed sync changed the success state: found=%t err=%v", found, err)
85+
}
86+
}
87+
88+
func TestRegistrySyncSuccessImportsAllowlistedRowsAndState(t *testing.T) {
89+
ctx := context.Background()
90+
db := newRegistrySyncTestDB(t)
91+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
92+
w.Header().Set("Content-Type", "application/json")
93+
_, _ = io.WriteString(w, `{"openai":{"id":"openai","name":"OpenAI","models":{"gpt-test":{"id":"gpt-test","name":"GPT Test","tool_call":true,"modalities":{"input":["text"],"output":["text"]},"limit":{"context":128000,"output":4096}}}}}`)
94+
}))
95+
defer server.Close()
96+
97+
if err := syncRegistry(context.Background(), registrySyncConfig(server.URL, []string{"openai/gpt-test"}), db, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
98+
t.Fatalf("syncRegistry failed: %v", err)
99+
}
100+
catalog, err := storage.LoadCatalog(ctx, db)
101+
if err != nil {
102+
t.Fatal(err)
103+
}
104+
if len(catalog.Providers) != 1 || len(catalog.Models) != 1 || len(catalog.Pairs) != 1 {
105+
t.Fatalf("unexpected imported catalog: providers=%d models=%d pairs=%d", len(catalog.Providers), len(catalog.Models), len(catalog.Pairs))
106+
}
107+
state, found, err := storage.LoadSyncState(ctx, db, models.SourceModelsDev)
108+
if err != nil || !found {
109+
t.Fatalf("sync state found=%t err=%v", found, err)
110+
}
111+
if state.ImportedPairs != 1 || state.URL != server.URL {
112+
t.Fatalf("unexpected sync state: %+v", state)
113+
}
114+
}

0 commit comments

Comments
 (0)