From aa777064b99d5b20a0bbe28bff084c2a9c6e2dbf Mon Sep 17 00:00:00 2001 From: qiantingwl <130087936+qiantingwl@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:08:09 +0800 Subject: [PATCH] feat: add optional maintenance sidecar reference and implementation report --- contrib/maintenance-sidecar/.dockerignore | 9 + contrib/maintenance-sidecar/.gitignore | 5 + contrib/maintenance-sidecar/Dockerfile | 9 + contrib/maintenance-sidecar/README.md | 30 + contrib/maintenance-sidecar/bridge/go.mod | 3 + contrib/maintenance-sidecar/bridge/main.go | 175 +++++ .../maintenance-sidecar/bridge/main_test.go | 128 ++++ contrib/maintenance-sidecar/dashboard.py | 112 +++ contrib/maintenance-sidecar/index.html | 18 + .../maintenance-sidecar/maintenance_policy.py | 48 ++ contrib/maintenance-sidecar/proxy_health.py | 97 +++ contrib/maintenance-sidecar/raccoon_ledger.py | 131 ++++ contrib/maintenance-sidecar/server.py | 641 ++++++++++++++++++ .../maintenance-sidecar/tests/boot_smoke.py | 17 + .../maintenance-sidecar/tests/test_policy.py | 68 ++ .../tests/test_proxy_health.py | 71 ++ contrib/maintenance-sidecar/ui.css | 2 + contrib/maintenance-sidecar/ui.js | 16 + contrib/maintenance-sidecar/ui_support.py | 81 +++ contrib/maintenance-sidecar/ux.js | 44 ++ .../maintenance-sidecar/vendor-manifest.json | 11 + .../vendor/LICENSE.tom-select | 202 ++++++ .../maintenance-sidecar/vendor/tom-select.css | 412 +++++++++++ .../vendor/tom-select.min.js | 444 ++++++++++++ docs/install.en.md | 5 + docs/install.ja.md | 5 + docs/install.ko.md | 5 + docs/install.md | 5 + docs/install.pt-BR.md | 5 + docs/install.zh-Hant.md | 5 + docs/maintenance-sidecar.md | 96 +++ 31 files changed, 2900 insertions(+) create mode 100644 contrib/maintenance-sidecar/.dockerignore create mode 100644 contrib/maintenance-sidecar/.gitignore create mode 100644 contrib/maintenance-sidecar/Dockerfile create mode 100644 contrib/maintenance-sidecar/README.md create mode 100644 contrib/maintenance-sidecar/bridge/go.mod create mode 100644 contrib/maintenance-sidecar/bridge/main.go create mode 100644 contrib/maintenance-sidecar/bridge/main_test.go create mode 100644 contrib/maintenance-sidecar/dashboard.py create mode 100644 contrib/maintenance-sidecar/index.html create mode 100644 contrib/maintenance-sidecar/maintenance_policy.py create mode 100644 contrib/maintenance-sidecar/proxy_health.py create mode 100644 contrib/maintenance-sidecar/raccoon_ledger.py create mode 100644 contrib/maintenance-sidecar/server.py create mode 100644 contrib/maintenance-sidecar/tests/boot_smoke.py create mode 100644 contrib/maintenance-sidecar/tests/test_policy.py create mode 100644 contrib/maintenance-sidecar/tests/test_proxy_health.py create mode 100644 contrib/maintenance-sidecar/ui.css create mode 100644 contrib/maintenance-sidecar/ui.js create mode 100644 contrib/maintenance-sidecar/ui_support.py create mode 100644 contrib/maintenance-sidecar/ux.js create mode 100644 contrib/maintenance-sidecar/vendor-manifest.json create mode 100644 contrib/maintenance-sidecar/vendor/LICENSE.tom-select create mode 100644 contrib/maintenance-sidecar/vendor/tom-select.css create mode 100644 contrib/maintenance-sidecar/vendor/tom-select.min.js create mode 100644 docs/maintenance-sidecar.md diff --git a/contrib/maintenance-sidecar/.dockerignore b/contrib/maintenance-sidecar/.dockerignore new file mode 100644 index 000000000..3064ad701 --- /dev/null +++ b/contrib/maintenance-sidecar/.dockerignore @@ -0,0 +1,9 @@ +* +!Dockerfile +!*.py +!*.js +!*.css +!*.html +!vendor-manifest.json +!vendor/ +!vendor/** diff --git a/contrib/maintenance-sidecar/.gitignore b/contrib/maintenance-sidecar/.gitignore new file mode 100644 index 000000000..d330e22c6 --- /dev/null +++ b/contrib/maintenance-sidecar/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +state/ +secrets/ +*.private.json diff --git a/contrib/maintenance-sidecar/Dockerfile b/contrib/maintenance-sidecar/Dockerfile new file mode 100644 index 000000000..52e4b7a3c --- /dev/null +++ b/contrib/maintenance-sidecar/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim-bookworm +RUN apt-get update && apt-get install -y --no-install-recommends libproxychains4 ca-certificates && rm -rf /var/lib/apt/lists/* +RUN pip install --no-cache-dir cryptography==44.0.3 +WORKDIR /app +COPY *.py *.js *.css *.html vendor-manifest.json /app/ +COPY vendor/ /app/vendor/ +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 TZ=Asia/Shanghai +EXPOSE 8080 +CMD ["python", "/app/server.py"] diff --git a/contrib/maintenance-sidecar/README.md b/contrib/maintenance-sidecar/README.md new file mode 100644 index 000000000..bb4b0a542 --- /dev/null +++ b/contrib/maintenance-sidecar/README.md @@ -0,0 +1,30 @@ +# Optional maintenance sidecar reference + +This is a source reference for an independently operated Loomy/Raccoon maintenance +worker and its dashboard. It is deliberately not enabled by the default Agent2API +image, Compose file, plugin registry or UI. No existing production configuration is +changed by checking out this directory. + +See [the implementation report](../../docs/maintenance-sidecar.md) for implemented +behaviour, integration contracts, limitations and isolated tests. Installation +status is recorded in [the installation guide](../../docs/install.en.md). + +The main binary and model forwarding code remain unchanged. Balance-cache +synchronisation still requires an opt-in usage bridge around the official provider +plugin; "unmodified host" does **not** mean "no integration needed". The bridge +source is provided for review, with no automatic plugin replacement script. + +The standalone Dockerfile builds only this source. It does not contain account +data, proxy subscriptions, operator keys, provider binaries or bridge configuration. +Runtime database/key/auth/bridge-key mounts and a compatible official provider +binary are operator-supplied. `MAINTENANCE_GATEWAY_URL` selects the management URL; +other `MAINTENANCE_*` paths/version/target values are declared in `server.py`. +Default startup is paused and automatic operations are disabled. + +Current validation targets Linux amd64 and the documented provider/database +contract. Treat this as a draft reference, not a cross-version drop-in installer. +Database writes for check-in and renewable credentials, encrypted secret callbacks, +capacity rejections and stale-balance handling remain explicit review points. + +Tom Select 2.4.3 is vendored under its MIT license; see `vendor/` and +`vendor-manifest.json`. The bridge uses only the Go standard library. diff --git a/contrib/maintenance-sidecar/bridge/go.mod b/contrib/maintenance-sidecar/bridge/go.mod new file mode 100644 index 000000000..e47bf8c88 --- /dev/null +++ b/contrib/maintenance-sidecar/bridge/go.mod @@ -0,0 +1,3 @@ +module agent2api-maintenance-bridge + +go 1.22 diff --git a/contrib/maintenance-sidecar/bridge/main.go b/contrib/maintenance-sidecar/bridge/main.go new file mode 100644 index 000000000..352503b8e --- /dev/null +++ b/contrib/maintenance-sidecar/bridge/main.go @@ -0,0 +1,175 @@ +// Local maintenance bridge. Passes all original plugin frames unchanged except +// usage.query for the two explicitly supported providers. No model interception. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "sync" + "syscall" + "time" +) + +type config struct { + URL string `json:"url"` + Token string `json:"token"` + Timeout int `json:"timeout_seconds"` +} +type frame struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} +type parameters struct { + Provider string `json:"provider"` + Account string `json:"account"` +} + +var output sync.Mutex + +func write(b []byte) { + output.Lock() + defer output.Unlock() + os.Stdout.Write(b) + if len(b) == 0 || b[len(b)-1] != '\n' { + os.Stdout.Write([]byte{'\n'}) + } +} +func failure(id json.RawMessage, message string) { + b, _ := json.Marshal(map[string]any{"id": id, "error": map[string]any{"message": message, "code": "maintenance_bridge"}}) + write(b) +} +func query(f frame, c config) { + req, err := http.NewRequest("POST", c.URL, bytes.NewReader(f.Params)) + if err != nil { + failure(f.ID, "维护桥请求配置错误") + return + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: time.Duration(c.Timeout) * time.Second, Transport: &http.Transport{Proxy: nil, DisableKeepAlives: true}, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + failure(f.ID, "维护容器连接失败或超时;未回退直连") + return + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + failure(f.ID, "维护结果读取失败") + return + } + var result map[string]json.RawMessage + if json.Unmarshal(body, &result) != nil { + failure(f.ID, "维护容器返回了无效结果") + return + } + if e, ok := result["error"]; ok { + b, _ := json.Marshal(map[string]any{"id": f.ID, "error": e}) + write(b) + return + } + if resp.StatusCode != 200 { + failure(f.ID, fmt.Sprintf("维护容器返回 HTTP %d", resp.StatusCode)) + return + } + u, ok := result["result"] + if !ok { + failure(f.ID, "维护结果缺少余额字段") + return + } + b, _ := json.Marshal(map[string]any{"id": f.ID, "result": u}) + write(b) +} +func main() { + exe, err := os.Executable() + if err != nil { + os.Exit(1) + } + cfgPath := "/data/plugins/maintenance-bridge.private.json" + if x := os.Getenv("A2A_MAINTENANCE_BRIDGE_CONFIG"); x != "" { + cfgPath = x + } + var cfg config + b, err := os.ReadFile(cfgPath) + if err != nil || json.Unmarshal(b, &cfg) != nil || cfg.Token == "" || cfg.URL == "" { + fmt.Fprintln(os.Stderr, "maintenance bridge config unavailable") + os.Exit(1) + } + if cfg.Timeout < 10 || cfg.Timeout > 110 { + cfg.Timeout = 70 + } + child := exec.Command(filepath.Join(filepath.Dir(exe), "agent2api-providers.official")) + child.Env = os.Environ() + child.Stderr = os.Stderr + stdin, err := child.StdinPipe() + if err != nil { + os.Exit(1) + } + stdout, err := child.StdoutPipe() + if err != nil { + os.Exit(1) + } + if child.Start() != nil { + os.Exit(1) + } + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-stop + child.Process.Signal(syscall.SIGTERM) + time.Sleep(2 * time.Second) + child.Process.Kill() + os.Exit(0) + }() + go func() { + reader := bufio.NewReader(stdout) + for { + line, e := reader.ReadBytes('\n') + if len(line) > 0 { + write(line) + } + if e != nil { + break + } + } + child.Wait() + os.Exit(0) + }() + reader := bufio.NewReader(os.Stdin) + semaphore := make(chan struct{}, 32) + for { + line, e := reader.ReadBytes('\n') + if len(line) > 0 { + var f frame + var p parameters + intercept := json.Unmarshal(line, &f) == nil && f.Method == "usage.query" && json.Unmarshal(f.Params, &p) == nil && (p.Provider == "raccoon" || p.Provider == "loomy") && p.Account != "" + if intercept { + select { + case semaphore <- struct{}{}: + go func(f frame) { defer func() { <-semaphore }(); query(f, cfg) }(f) + default: + failure(f.ID, "维护余额并发已满,请由维护队列分批提交") + } + } else { + if _, err := stdin.Write(line); err != nil { + break + } + } + } + if e != nil { + break + } + } + stdin.Close() + child.Process.Signal(syscall.SIGTERM) + child.Wait() +} diff --git a/contrib/maintenance-sidecar/bridge/main_test.go b/contrib/maintenance-sidecar/bridge/main_test.go new file mode 100644 index 000000000..e498e69ef --- /dev/null +++ b/contrib/maintenance-sidecar/bridge/main_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "bufio" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestUsageBridgeAndUnchangedModelFrames(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The reference adapter uses Linux process signals and a fixture shell child") + } + requests := make(chan map[string]string, 4) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.Header.Get("Authorization") != "Bearer fixture-only" { + t.Error("Unexpected method or missing bridge authentication") + w.WriteHeader(403) + return + } + var params map[string]string + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + t.Error(err) + } + requests <- params + w.Header().Set("Content-Type", "application/json") + if params["account"] == "error" { + w.Write([]byte(`{"error":{"code":"upstream","message":"fixture rejected"}}`)) + return + } + w.Write([]byte(`{"result":{"totalLeft":123}}`)) + })) + defer upstream.Close() + dir := t.TempDir() + bridge := filepath.Join(dir, "bridge") + if data, err := exec.Command("go", "build", "-o", bridge, ".").CombinedOutput(); err != nil { + t.Fatalf("build: %v %s", err, data) + } + // The original plugin is replaced only inside this temporary fixture directory. + if err := os.WriteFile(filepath.Join(dir, "agent2api-providers.official"), []byte("#!/bin/sh\nexec cat\n"), 0700); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(dir, "fixture.json") + data, _ := json.Marshal(config{URL: upstream.URL, Token: "fixture-only", Timeout: 10}) + if err := os.WriteFile(configPath, data, 0600); err != nil { + t.Fatal(err) + } + cmd := exec.Command(bridge) + cmd.Env = append(os.Environ(), "A2A_MAINTENANCE_BRIDGE_CONFIG="+configPath) + stdin, _ := cmd.StdinPipe() + stdout, _ := cmd.StdoutPipe() + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { stdin.Close(); cmd.Process.Kill(); cmd.Wait() }() + scanner := bufio.NewScanner(stdout) + lines := make(chan string, 8) + go func() { + for scanner.Scan() { + lines <- scanner.Text() + } + close(lines) + }() + read := func() string { + select { + case line, ok := <-lines: + if !ok { + t.Fatal("Bridge exited unexpectedly") + } + return line + case <-time.After(15 * time.Second): + t.Fatal("Timed out waiting for fixture response") + return "" + } + } + // Model calls, unrelated providers and plugin callbacks are byte-preserved. + for _, frame := range []string{ + `{"id":1,"method":"chat.completions","params":{"provider":"loomy","account":"fixture"}}`, + `{"id":2,"method":"usage.query","params":{"provider":"other","account":"fixture"}}`, + `{"id":3,"result":{"secret":"synthetic-callback"}}`, + } { + stdin.Write([]byte(frame + "\n")) + if got := read(); got != frame { + t.Fatalf("frame changed: %s", got) + } + } + for _, provider := range []string{"loomy", "raccoon"} { + frame := `{"id":4,"method":"usage.query","params":{"provider":"` + provider + `","account":"fixture"}}` + stdin.Write([]byte(frame + "\n")) + var response struct { + ID int + Result struct{ TotalLeft int } + Error any + } + if err := json.Unmarshal([]byte(read()), &response); err != nil { + t.Fatal(err) + } + if response.ID != 4 || response.Result.TotalLeft != 123 || response.Error != nil { + t.Fatalf("bad response: %+v", response) + } + select { + case params := <-requests: + if params["provider"] != provider { + t.Fatal("provider not forwarded") + } + case <-time.After(time.Second): + t.Fatal("bridge not called") + } + } + stdin.Write([]byte(`{"id":5,"method":"usage.query","params":{"provider":"loomy","account":"error"}}` + "\n")) + var failure struct { + ID int + Error struct{ Code, Message string } + Result any + } + if err := json.Unmarshal([]byte(read()), &failure); err != nil { + t.Fatal(err) + } + if failure.ID != 5 || failure.Error.Code != "upstream" || failure.Result != nil { + t.Fatalf("upstream error not preserved: %+v", failure) + } +} diff --git a/contrib/maintenance-sidecar/dashboard.py b/contrib/maintenance-sidecar/dashboard.py new file mode 100644 index 000000000..e653de7da --- /dev/null +++ b/contrib/maintenance-sidecar/dashboard.py @@ -0,0 +1,112 @@ +"""Read-only operational metrics. Official routing state is not inferred from balance.""" +import datetime,math +from collections import Counter +from maintenance_policy import balance_interval, credential_due, balance_satisfied, balance_waits_for_checkin +FAILED={'error','cooling','needs_login','interrupted'} + +def queue_order(job,accounts): + row=accounts.get(job['account'],{});action=job['action'] + return (0 if action=='credential' else 1 if action=='checkin' else 2 if action=='balance' and (row.get('balance') or 0)>0 else 3 if job.get('origin')=='post_checkin' else 4, + (row.get('credentialExpiresAt') or 2**62) if action=='credential' else 0, + job.get('dispatch_rank',0),job['created'],job['id']) + +def filters(query): + value=lambda k:query.get(k,[''])[0] + page=max(0,min(100000,int(value('page') or 0)));size=max(1,min(100,int(value('size') or 50))) + return value,page,size + +def queue_view(snapshot,config,db,timestamp,query,available_routes=None): + value,page,size=filters(query);accounts={r['id']:r for r in snapshot['accounts']} + with db() as c: + jobs=[dict(x) for x in c.execute("select j.*,row_number() over(partition by provider,action,status order by priority desc,created,id) dispatch_rank from jobs j where provider in ('raccoon','loomy') and status in ('queued','running')")] + jobs.sort(key=lambda j:(0 if j['status']=='running' else 1,queue_order(j,accounts))) + counts=dict(Counter(j['status'] for j in jobs));rank=0;selected=[] + for job in jobs: + row=accounts.get(job['account'],{});cfg=config['providers'].get(job['provider'],{}).get(job['action'],{}) + if job['status']=='queued':rank+=1 + job['position']=rank if job['status']=='queued' else None;job['name']=row.get('name',job['account']);job['waitSeconds']=max(0,(timestamp-job['created'])//1000) + job['cooldownUntil']=row.get('cooldownUntil',0) + job['holdReason']='' + if job['status']=='running':job['holdReason']='执行中(暂停不会中断已发出的请求)' + elif config['paused']:job['holdReason']='全局暂停' + elif cfg.get('paused'):job['holdReason']='此平台操作队列暂停' + elif row.get('cooldownUntil',0)>timestamp:job['holdReason']='冷却中' + elif available_routes is not None and cfg.get('route','auto') not in available_routes:job['holdReason']='等待可用维护节点(不回退直连)' + elif job['action']=='balance' and job.get('origin')=='auto' and balance_waits_for_checkin(row):job['holdReason']='已确认零积分,等待下次签到成功后查询' + elif job['action']=='balance' and balance_satisfied(job,row,timestamp,cfg):job['holdReason']='已有新余额,执行前自动跳过' + elif job['action']=='checkin' and job.get('attempt',0)>=0: + clock=datetime.datetime.fromtimestamp(timestamp/1000,datetime.timezone(datetime.timedelta(hours=8))).strftime('%H:%M') + if not cfg.get('start','00:00')<=clock=? group by provider,action,status',(start,))] + unique=[dict(x) for x in c.execute('select provider,action,count(distinct account) accounts from jobs where created>=? group by provider,action',(start,))] + attempts={x[0]:x[1] for x in c.execute("select account,count(*) from jobs where action='checkin' and attempt>=0 and created>=? and status not in ('cancelled','skipped') group by account",(start,))} + latest={(x['account'],x['action']):dict(x) for x in c.execute('select * from (select *,row_number() over(partition by account,action order by created desc,rowid desc) rn from jobs) where rn=1')} + pending=[dict(x) for x in c.execute("select provider,action,status,count(*) attempts from jobs where provider in ('raccoon','loomy') and status in ('queued','running') group by provider,action,status")] + rates=[dict(x) for x in c.execute("select provider,action,count(*) completed,sum(status='ok') succeeded,round(avg((ended-started)/1000.0),2) meanSeconds from jobs where ended>=? and started is not null and status in ('ok','error','cooling','needs_login','unconfirmed') group by provider,action",(timestamp-600000,))] + fresh_routing=bool(routing.get('at') and timestamp-routing['at']<=120000 and not routing.get('error')) + rows=[] + for original in snapshot['accounts']: + r=dict(original);balance=r['balance'];known=bool(r['lastSuccess'] and isinstance(balance,(int,float)) and math.isfinite(balance)) + r['balanceKind']='unlimited' if r['unlimited'] else 'unknown' if not known else 'positive' if balance>0 else 'zero' if balance==0 else 'negative' + cfgp=config['providers'].get(r['provider'],{});cp=cfgp.get('checkin',{});bp=cfgp.get('balance',{'interval':3600});cred=cfgp.get('credential',{}) + j=latest.get((r['id'],'checkin'),{});b=latest.get((r['id'],'balance'),{});cr=latest.get((r['id'],'credential'),{}) + count=attempts.get(r['id'],0);maximum=cp.get('maxAttempts',3);next_sign=0 + if r['signed']:sign='signed' + elif not r['supported']:sign='unsupported' + elif j.get('status') in ('queued','running'):sign=j['status'] + elif r['needsLogin']:sign='needs_login' + elif not cp.get('enabled'):sign='disabled' + elif count>=maximum:sign='exhausted' + else: + prev=j.get('created',0);next_sign=max(timestamp,r['cooldownUntil'],prev+cp.get('retryInterval',1800)*1000 if prev>=start else 0) + sh,sm=map(int,cp.get('start','00:00').split(':'));eh,em=map(int,cp.get('end','23:00').split(':')) + ws=int(day.replace(hour=sh,minute=sm).timestamp()*1000);we=int(day.replace(hour=eh,minute=em).timestamp()*1000) + if next_sign>=we:next_sign=max(next_sign,ws+86400000);sign='outside_window' + elif next_signtimestamp else 'eligible' + if not r['signed'] and (config['paused'] or cp.get('paused')) and j.get('status')!='running':sign='paused' + interval=balance_interval(r,bp);previous=max(b.get('created',0),r['lastAttempt'],r['lastSuccess']);due=previous+interval*1000 if previous else timestamp + if r.get('signedAt',0)>r['lastSuccess']:due=timestamp + waiting_sign=balance_waits_for_checkin(r) and not (b.get('origin')=='post_checkin' and b.get('required_after',0)>r['lastSuccess'] and b.get('status') in ('queued','running')) + balance_state='awaiting_checkin' if waiting_sign else 'waiting' if due>timestamp else 'due' + if r['cooldownUntil']>timestamp:balance_state='cooling' + if not bp.get('enabled'):balance_state='disabled' + if r['needsLogin']:balance_state='needs_login' + if b.get('status')=='running' or (b.get('status')=='queued' and not waiting_sign):balance_state=b['status'] + if config['paused'] or bp.get('paused'): + if balance_state!='running':balance_state='paused' + r['checkinDetail']={'state':sign,'attempts':count,'maxAttempts':maximum,'nextEligibleAt':next_sign,'lastStatus':j.get('status',''),'lastMessage':j.get('message',''),'failedToday':not r['signed'] and j.get('created',0)>=start and j.get('status') in FAILED} + r['balanceDetail']={'state':balance_state,'status':b.get('status',''),'message':b.get('message',''),'nextEligibleAt':None if waiting_sign else max(due,r['cooldownUntil']),'waitingForCheckin':waiting_sign,'dueAt':None if waiting_sign else due,'interval':interval,'lastSuccess':r['lastSuccess'],'origin':b.get('origin',''),'jobId':b.get('id'),'fresh':known and timestamp-r['lastSuccess']<=interval*1000,'due':not waiting_sign and due<=timestamp and not config['paused'] and r['enabled'] and not r['needsLogin'] and r['cooldownUntil']<=timestamp and bp.get('enabled',False) and not bp.get('paused',False) and balance_state not in ('queued','running')} + route=routing.get('entries',{}).get(r['id'],{});r['routing']={'state':route.get('state','absent') if fresh_routing else 'unknown','reason':route.get('reason','未进入官方调度队列') if fresh_routing else routing.get('error') or '调度快照已过期','at':routing.get('at',0)} + r['credentialDetail']={'status':cr.get('status',''),'message':cr.get('message',''),'due':credential_due(r,cr.get('created',0),timestamp,cred),'renewable':r.get('credentialRenewable',False),'expiresAt':r.get('credentialExpiresAt',0),'reason':r.get('credentialReason','未知')} + rows.append(r) + platforms={} + for p in sorted({r['provider'] for r in rows}): + rr=[r for r in rows if r['provider']==p];finite=[r for r in rr if r['balanceKind'] not in ('unknown','unlimited')];usable=[r for r in rr if r['routing']['state']=='usable'] + platforms[p]={'total':len(rr),'enabled':sum(r['enabled'] for r in rr),'signed':sum(r['signed'] for r in rr),'unsigned':sum(not r['signed'] for r in rr),'balance':dict(Counter(r['balanceKind'] for r in rr)),'checkin':dict(Counter(r['checkinDetail']['state'] for r in rr)),'balanceStates':dict(Counter(r['balanceDetail']['state'] for r in rr)),'positivePoints':sum(max(0,r['balance']) for r in finite),'routable':len(usable) if fresh_routing else None,'routablePoints':sum(max(0,r['balance']) for r in usable if r['balanceKind'] not in ('unknown','unlimited')) if fresh_routing else None,'routableUnknown':sum(r['balanceKind']=='unknown' for r in usable),'routableUnlimited':sum(r['unlimited'] for r in usable),'routingStates':dict(Counter(r['routing']['state'] for r in rr)),'fresh':sum(r['balanceDetail']['fresh'] for r in rr),'due':sum(r['balanceDetail']['due'] for r in rr),'stale':sum(r['lastSuccess']>0 and not r['balanceDetail']['fresh'] for r in rr),'cooling':sum(r['cooldownUntil']>timestamp for r in rr),'needsLogin':sum(r['needsLogin'] for r in rr),'renewable':sum(r.get('credentialRenewable',False) for r in rr),'failedUnfinished':sum(r['checkinDetail']['failedToday'] for r in rr)} + result=dict(snapshot);result['accounts']=rows + result['dashboard']={'date':day.strftime('%Y-%m-%d'),'timezone':'Asia/Shanghai','platforms':platforms,'todayAttempts':stats,'todayUnique':unique,'pending':pending,'throughput10m':rates,'routing':{k:v for k,v in routing.items() if k!='entries'},'routingFresh':fresh_routing,'balanceNote':'积分为最近成功查询的记录,不是实时扣费余额;不同平台不合计。','retryNote':'到期时间是最早入队时间,不是完成承诺;失败受冷却与重试间隔约束。','recordLimit':150} + return result diff --git a/contrib/maintenance-sidecar/index.html b/contrib/maintenance-sidecar/index.html new file mode 100644 index 000000000..ac21a4e6d --- /dev/null +++ b/contrib/maintenance-sidecar/index.html @@ -0,0 +1,18 @@ +维护中心 · Agent2API
正在连接维护中心

加载 Loomy · 小浣熊运行状态

+ +
!

确认操作

\ No newline at end of file diff --git a/contrib/maintenance-sidecar/maintenance_policy.py b/contrib/maintenance-sidecar/maintenance_policy.py new file mode 100644 index 000000000..5aea3c7a1 --- /dev/null +++ b/contrib/maintenance-sidecar/maintenance_policy.py @@ -0,0 +1,48 @@ +"""Scheduling metadata only; renewal decisions stay with the official plugin.""" +import base64, json + +def credential_meta(provider, secret): + if provider != 'raccoon': + return {'credentialRenewable': False, 'credentialExpiresAt': 0, 'credentialReason': '官方插件不支持续期'} + token = next((secret.get(k) for k in ('accessToken','token','access_token') if isinstance(secret.get(k),str) and secret[k]), '') + if token.lower().startswith('bearer '): token = token[7:].strip() + claims = {} + try: + part=token.split('.')[1]; claims=json.loads(base64.urlsafe_b64decode(part+'='*(-len(part)%4))) + if not isinstance(claims,dict): claims={} + except (ValueError,IndexError,TypeError): pass + refresh=next((secret.get(k) for k in ('refreshToken','refresh_token') if isinstance(secret.get(k),str) and secret[k].strip()), '') + iam=str(claims.get('iss','')).endswith('/iam') + expiry=0 + for value,seconds in [(secret.get('expiresAt'),False),(claims.get('exp'),True),(secret.get('tokenExpiresAt'),False)]: + try: + value=float(value) + if value>0: expiry=int(value*1000 if seconds or value<100000000000 else value);break + except (ValueError,TypeError,OverflowError): pass + renewable=bool(token and refresh and not iam) + return {'credentialRenewable':renewable,'credentialExpiresAt':expiry,'credentialReason': '支持官方续期' if renewable else 'IAM凭证不支持刷新令牌续期' if iam else '缺少可续期凭证'} + +def balance_interval(row, config): + return config.get('positiveInterval',config['interval']) if (row.get('balance') or 0)>0 else config['interval'] + +def credential_due(row, previous, timestamp, config): + if not row.get('credentialRenewable'):return False + expires=row.get('credentialExpiresAt',0) + if expires and expires>timestamp+300000:return False + interval=config.get('interval',300) if expires else max(3600,config.get('interval',300)) + return timestamp-previous>=interval*1000 + +def balance_satisfied(job,row,timestamp,config): + success=row.get('lastSuccess',0) + if not success:return False + required=job.get('required_after',0) + if required:return success>=required + if row.get('signedAt',0)>success:return False + return job.get('origin')=='auto' and timestamp-success 0 + and row.get('signedAt', 0) <= row.get('lastSuccess', 0)) diff --git a/contrib/maintenance-sidecar/proxy_health.py b/contrib/maintenance-sidecar/proxy_health.py new file mode 100644 index 000000000..14ad91346 --- /dev/null +++ b/contrib/maintenance-sidecar/proxy_health.py @@ -0,0 +1,97 @@ +"""Scheduled proxy quarantine, kept separate from the operator's enable switch.""" +import hashlib,json,random,threading,time +from ui_support import ProxyHealth + +class ProxyUnavailable(RuntimeError): + pass + +DEFAULT_POLICY={'enabled':False,'interval':900,'retryInterval':300,'confirmInterval':60,'failureThreshold':2,'recoveryThreshold':2,'concurrency':3} + +class AutoProxyHealth(ProxyHealth): + def __init__(self,db,decode,settings): + super().__init__(db,decode,settings) + self.last_tick=0;self.last_error='';self.last_error_log=0 + with db() as c: + c.execute('create table if not exists proxy_health_state(id text primary key,fail_streak integer not null default 0,success_streak integer not null default 0,quarantined integer not null default 0,next_check_at integer not null default 0,changed_at integer not null default 0)') + def policy(self): + with self.db() as c:row=c.execute("select value from settings where key='proxyHealth'").fetchone() + return {**DEFAULT_POLICY,**(json.loads(row[0]) if row else {})} + def save_policy(self,data): + if not isinstance(data,dict):raise ValueError('检测设置必须为对象') + cfg=self.policy() + if 'enabled' in data: + if not isinstance(data['enabled'],bool):raise ValueError('enabled 必须为布尔值') + cfg['enabled']=data['enabled'] + for key,lo,hi in [('interval',300,86400),('retryInterval',60,3600),('confirmInterval',30,600),('failureThreshold',2,5),('recoveryThreshold',2,5),('concurrency',1,3)]: + if key in data: + if isinstance(data[key],bool):raise ValueError('参数类型错误') + n=int(data[key]) + if nhi:raise ValueError(key+' 超出允许范围') + cfg[key]=n + with self.db() as c:c.execute('insert or replace into settings(key,value) values(?,?)',('proxyHealth',json.dumps(cfg))) + return cfg + def rows(self): + rows=super().rows() + with self.db() as c:health={r['id']:dict(r) for r in c.execute('select * from proxy_health_state')} + for r in rows: + h=health.get(r['id'],{});r.update(autoBlocked=bool(h.get('quarantined')),failureStreak=h.get('fail_streak',0),successStreak=h.get('success_streak',0),nextCheckAt=h.get('next_check_at',0),healthChangedAt=h.get('changed_at',0)) + r['usable']=bool(r['enabled'] and not r['autoBlocked']) + return rows + def candidates(self,route): + if route=='direct':return [] + with self.db() as c: + sql='select p.* from proxies p left join proxy_health_state h on h.id=p.id where p.enabled=1 and coalesce(h.quarantined,0)=0' + return [dict(r) for r in c.execute(sql+(' and p.id=?' if route!='auto' else ''),() if route=='auto' else (route,))] + def available_routes(self): + with self.db() as c:ids={r[0] for r in c.execute('select p.id from proxies p left join proxy_health_state h on p.id=h.id where p.enabled=1 and coalesce(h.quarantined,0)=0')} + return {'direct',*ids,*({'auto'} if ids else set())} + def require_available(self,route): + if route not in self.available_routes():raise ProxyUnavailable('MAINTENANCE_PROXY_WAIT: 无可用维护节点,保留排队;不会回退直连') + def delete(self,pid): + result=super().delete(pid) + with self.db() as c:c.execute('delete from proxy_health_state where id=?',(pid,)) + return result + def status(self): + cfg=self.policy();rows=self.rows() + return {'config':cfg,'lastTickAt':self.last_tick,'error':self.last_error,'usable':sum(r['usable'] for r in rows),'quarantined':sum(r['autoBlocked'] and r['enabled'] for r in rows),'manuallyDisabled':sum(not r['enabled'] for r in rows),'testing':sum(r['testing'] for r in rows),'total':len(rows)} + def on_result(self,pid,status): + cfg=self.policy();ts=int(time.time()*1000);good=status=='ok' + with self.db() as c: + c.execute('begin immediate') + if not c.execute('select 1 from proxies where id=?',(pid,)).fetchone():return + old=c.execute('select * from proxy_health_state where id=?',(pid,)).fetchone();old=dict(old) if old else {} + blocked=bool(old.get('quarantined',0));was=blocked + failures=0 if good else old.get('fail_streak',0)+1 + successes=old.get('success_streak',0)+1 if good else 0 + if not good and failures>=cfg['failureThreshold']:blocked=True + if good and blocked and successes>=cfg['recoveryThreshold']:blocked=False + interval=cfg['confirmInterval'] if (good and blocked) or (not good and not blocked) else cfg['retryInterval'] if blocked else cfg['interval'] + next_at=ts+interval*1000+random.randint(0,min(30000,interval*100)) + changed=ts if blocked!=was else old.get('changed_at',0) + c.execute('insert or replace into proxy_health_state values(?,?,?,?,?,?)',(pid,failures,successes,int(blocked),next_at,changed)) + if blocked!=was: + label=c.execute('select label from proxies where id=?',(pid,)).fetchone()[0] + c.execute('insert into events(ts,kind,message) values(?,?,?)',(ts,'proxy_health',('自动隔离节点:' if blocked else '节点检测恢复,重新加入自动候选:')+str(label)[:60])) + def tick(self,timestamp=None): + cfg=self.policy();ts=timestamp if timestamp is not None else int(time.time()*1000);self.last_tick=ts + if not cfg['enabled']:return 0 + with self.db() as c: + ids=[r[0] for r in c.execute('select id from proxies where enabled=1')] + for pid in ids: + spread=int(hashlib.sha256(pid.encode()).hexdigest()[:8],16)%120000 + c.execute('insert or ignore into proxy_health_state(id,next_check_at) values(?,?)',(pid,ts+spread)) + due=[r[0] for r in c.execute('select h.id from proxy_health_state h join proxies p on p.id=h.id where p.enabled=1 and h.next_check_at<=? order by h.next_check_at,h.id',(ts,))] + with self.lock: + slots=max(0,cfg['concurrency']-len(self.active));ids=[pid for pid in due if pid not in self.active][:slots] + if not ids:return 0 + return self.start(ids)['queued'] + def run(self,stop): + while not stop.wait(2): + try:self.tick();self.last_error='' + except Exception as e: + self.last_error='节点巡检暂时异常:'+type(e).__name__ + if time.time()-self.last_error_log>=60: + self.last_error_log=time.time() + try: + with self.db() as c:c.execute('insert into events(ts,kind,message) values(?,?,?)',(int(time.time()*1000),'proxy_health_error',self.last_error)) + except Exception:pass diff --git a/contrib/maintenance-sidecar/raccoon_ledger.py b/contrib/maintenance-sidecar/raccoon_ledger.py new file mode 100644 index 000000000..aec37ca10 --- /dev/null +++ b/contrib/maintenance-sidecar/raccoon_ledger.py @@ -0,0 +1,131 @@ +"""Read-only Raccoon daily-grant verification; never calls the grant trigger.""" +import datetime +import json +import math +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +TZ = datetime.timezone(datetime.timedelta(hours=8)) +PAGE_SIZE = 100 +MAX_PAGES = 100 + +def date_of(value): + if not isinstance(value, str): + return None + try: + parsed = datetime.datetime.fromisoformat(value.replace('Z', '+00:00')) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=TZ) + return parsed.astimezone(TZ).date().isoformat() + except ValueError: + try: + return datetime.date.fromisoformat(value[:10]).isoformat() + except ValueError: + return None + +def daily_grant(item, day): + kind = item.get('biz_type') + if not (kind == 'daily_grant' or kind is None and item.get('event_name') == '每日积分发放'): + return None + points = item.get('points') + if isinstance(points, bool): + return None + try: + points = float(points) + except (ValueError, TypeError): + return None + if not math.isfinite(points) or points <= 0 or date_of(item.get('grant_at') or item.get('created_at')) != day: + return None + return {k: item[k] for k in ('biz_type', 'event_name', 'points', 'grant_at', 'created_at', 'expire_at') if k in item} + +def scan(get_page, day, max_pages=MAX_PAGES): + datetime.date.fromisoformat(day) + cursor = '' + seen = set() + records = 0 + reason = 'page_limit' + pages = 0 + for page_no in range(1, max_pages + 1): + data = get_page(cursor, PAGE_SIZE) + if not isinstance(data, dict) or not isinstance(data.get('items'), list): + raise ValueError('账单响应缺少 items,不能确认签到') + items = data['items'] + if any(not isinstance(item, dict) for item in items): + raise ValueError('账单条目格式错误,不能确认签到') + pages = page_no + for offset, item in enumerate(items): + grant = daily_grant(item, day) + if grant is not None: + evidence = {'date': day, 'pages': pages, 'recordsScanned': records + offset + 1, 'grants': [grant], 'source': 'raccoon-points-bills', 'readOnly': True} + return {'outcome': 'done', 'message': '账单确认今日已入账 +' + str(grant['points']) + ' 积分(未重复领取)', 'verifiedDate': day, 'evidence': evidence} + records += len(items) + # Billing order follows creation time, not grant_at (which may be backdated). + dates = [date_of(item.get('created_at')) for item in items] + if not items or any(date is not None and date < day for date in dates): + reason = 'end_of_day' + break + paging = data.get('paging') or {} + if not isinstance(paging, dict): + paging = {} + nxt = data.get('next_cursor') or data.get('cursor') or paging.get('next_cursor') or paging.get('cursor') + more = data.get('has_more', paging.get('has_more')) + if more is False: + reason = 'end_of_history' + break + if not nxt: + reason = 'missing_cursor' if more is True else 'end_of_history' + break + if not isinstance(nxt, str) or nxt in seen: + reason = 'cursor_loop' + break + seen.add(nxt) + cursor = nxt + complete = reason in ('end_of_day', 'end_of_history') + return {'outcome': 'inactive', 'message': ('当天账单核对未发现每日积分入账' if complete else '账单核对未完成,不能确认今日入账') + '(未重复领取)', 'verifiedDate': day, 'evidence': {'date': day, 'pages': pages, 'recordsScanned': records, 'grants': [], 'source': 'raccoon-points-bills', 'readOnly': True, 'complete': complete, 'reason': reason}} + +def main(): + payload = json.load(sys.stdin) + token = payload['token'].removeprefix('Bearer ').strip() + if not token: + raise ValueError('HTTP 401: 账户缺少可用凭证') + identity = payload.get('identity') or '' + headers = {'Accept': 'application/json', 'Authorization': 'Bearer ' + token} + if identity and identity != 'personal': + if not identity.isascii() or any(ord(c) < 32 or ord(c) == 127 for c in identity): + raise ValueError('组织身份无效') + headers['X-Org-Code'] = identity + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + deadline = time.monotonic() + min(170, max(10, payload.get('timeout', 120))) + def get_page(cursor, limit): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError('账单核对超时,未确认成功') + query = {'paging.limit': str(limit), 'type': 'all'} + if cursor: + query['cursor'] = cursor + request = urllib.request.Request('https://xiaohuanxiong.com/api/web/points/v2/bills?' + urllib.parse.urlencode(query), headers=headers) + with opener.open(request, timeout=min(25, remaining)) as response: + result = json.load(response) + if not isinstance(result, dict): + raise ValueError('账单响应格式错误') + code = result.get('code', 0) + if code not in (0, None): + if code in (401, '401'): + raise ValueError('HTTP 401: 账单凭证无效') + raise ValueError('上游账单返回非成功业务状态') + return result.get('data', result) + try: + claim = scan(get_page, payload['date']) + print(json.dumps({'claim': claim}, ensure_ascii=False)) + except urllib.error.HTTPError as error: + print(json.dumps({'error': '账单核对 HTTP ' + str(error.code)}, ensure_ascii=False)) + except Exception as error: + # Never emit request headers, tokens, proxy secrets or complete response bodies. + message = str(error) if isinstance(error, (ValueError, TimeoutError)) else '账单核对连接失败:' + type(error).__name__ + print(json.dumps({'error': message}, ensure_ascii=False)) + +if __name__ == '__main__': + main() diff --git a/contrib/maintenance-sidecar/server.py b/contrib/maintenance-sidecar/server.py new file mode 100644 index 000000000..1a528b1fc --- /dev/null +++ b/contrib/maintenance-sidecar/server.py @@ -0,0 +1,641 @@ +from proxy_health import AutoProxyHealth, ProxyUnavailable +import gzip +from ui_support import scope_snapshot, account_page, ProxyHealth +"""Independent Agent2API maintenance controller. Official provider binary is never patched.""" +import sys +import base64, collections, concurrent.futures, contextlib, datetime, hashlib, hmac, http.cookies, http.cookiejar, json, math, os, pathlib, queue, random, re, secrets, signal, sqlite3, subprocess, threading, time, urllib.request, urllib.error, urllib.parse +from dashboard import enrich, queue_order, queue_view, history_view +from maintenance_policy import credential_meta, credential_due, balance_interval, balance_satisfied, balance_waits_for_checkin +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +ROOT=pathlib.Path(os.environ.get('MAINTENANCE_STATE_DIR','/state')); ROOT.mkdir(exist_ok=True) +MAIN=pathlib.Path(os.environ.get('MAINTENANCE_DATABASE','/gateway/agent2api.db')); KEY=pathlib.Path(os.environ.get('MAINTENANCE_ACCOUNT_KEY','/key/account.key')).read_bytes(); AES=AESGCM(KEY) +AUTH=json.loads(pathlib.Path(os.environ.get('MAINTENANCE_AUTH_FILE','/secrets/auth.json')).read_text()); PLUGIN=os.environ.get('MAINTENANCE_PLUGIN','/official/agent2api-providers') +GATEWAY=os.environ.get('MAINTENANCE_GATEWAY_URL','http://gateway:3065').rstrip('/') +PLUGIN_HOST_VERSION=os.environ.get('MAINTENANCE_HOST_VERSION','3.4.2') +PLUGIN_TARGET=os.environ.get('MAINTENANCE_PLUGIN_TARGET','x86_64-unknown-linux-gnu') +TZ=datetime.timezone(datetime.timedelta(hours=8)); STOP=threading.Event(); LOCK=threading.RLock(); SNAP={}; ACTIVE={}; SESSIONS={}; LOGIN_ATTEMPTS={} +SUPPORTED={'loomy','raccoon'} +BRIDGE_BUSY={} +ROUTING={'at':0,'entries':{},'error':'官方调度快照尚未加载','scope':'未指定模型的官方调度队列'} +ROUTING_LOCK=threading.RLock() +BRIDGE_KEY=pathlib.Path(os.environ.get('MAINTENANCE_BRIDGE_KEY_FILE','/secrets/bridge.key')).read_text().strip() +def now(): return int(time.time()*1000) +def today(): return datetime.datetime.now(TZ).strftime('%Y-%m-%d') +def dumps(v): return json.dumps(v,ensure_ascii=False,separators=(',',':')) +@contextlib.contextmanager +def db(main=False,ro=False): + p=MAIN if main else ROOT/'maintenance.db'; c=sqlite3.connect('file:'+str(p)+('?mode=ro' if ro else '?mode=rw') if main else str(p),uri=main,timeout=8);c.row_factory=sqlite3.Row;c.execute('pragma busy_timeout=8000') + if ro:c.execute('pragma query_only=on') + try:yield c;c.commit() + except: c.rollback();raise + finally:c.close() +def decode(v): + if not v.startswith('enc1:'):return json.loads(v) + b=base64.b64decode(v[5:]);return json.loads(AES.decrypt(b[:12],b[12:],None)) +def encode(v): + n=os.urandom(12);return 'enc1:'+base64.b64encode(n+AES.encrypt(n,dumps(v).encode(),None)).decode() +def account(aid): + with db(True,True) as c:r=c.execute('select * from accounts where id=?',(aid,)).fetchone() + if not r:raise RuntimeError('账户已不存在') + return dict(r),decode(r['data']) +def secret_of(d): + if isinstance(d.get('pluginSecret'),dict):return d['pluginSecret'] + return {k:v for k,v in d.items() if k not in {'name','provider','priority','enabled','addedAt','updatedAt','usageQuery','limiters','limiterGroups','proxy','maxConcurrent','checkinAt','lowBalance','limiterMode'}} +def scrub(s): + s=str(s);s=re.sub(r'https?://\S+','[上游地址]',s);s=re.sub(r'(?i)(bearer\s+|token[=: ]+|auth[=: ]+|session[=: ]+)[^\s,;]+',r'\1[隐藏]',s);s=re.sub(r'[A-Za-z0-9_+/=.-]{40,}','[隐藏]',s) + return s[:220] +with db() as c: + c.executescript('''PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY,account TEXT,provider TEXT,action TEXT,status TEXT,created INTEGER,started INTEGER,ended INTEGER,message TEXT,proxy TEXT,attempt INTEGER DEFAULT 0); + CREATE INDEX IF NOT EXISTS jobs_lookup ON jobs(account,action,created); + CREATE TABLE IF NOT EXISTS cooldowns(scope TEXT PRIMARY KEY,until INTEGER,failures INTEGER,reason TEXT); + CREATE TABLE IF NOT EXISTS proxies(id TEXT PRIMARY KEY,label TEXT,secret TEXT,enabled INTEGER); + CREATE TABLE IF NOT EXISTS ledger(key TEXT PRIMARY KEY,value TEXT); + CREATE TABLE IF NOT EXISTS events(ts INTEGER,kind TEXT,message TEXT); + CREATE TABLE IF NOT EXISTS bridge_audit(ts INTEGER,account TEXT,provider TEXT,proxy TEXT,status TEXT,duration_ms INTEGER); + ''') + columns={row[1] for row in c.execute('pragma table_info(jobs)')} + for name,decl in [('origin',"TEXT NOT NULL DEFAULT 'manual'"),('priority','INTEGER NOT NULL DEFAULT 0'),('required_after','INTEGER NOT NULL DEFAULT 0')]: + if name not in columns:c.execute('alter table jobs add column '+name+' '+decl) + c.execute("update jobs set status='interrupted',ended=?,message='服务重启;未自动重试,请人工核对签到结果' where status='running'",(now(),)) +with db() as c: + row=c.execute("select value from settings where key='config'").fetchone() + if row: + val=json.loads(row[0]);val['paused']=True;c.execute("update settings set value=? where key='config'",(dumps(val),)) +DEFAULT={'paused':True,'globalConcurrency':2,'timeout':45,'providers':{p:{'concurrency':1,'jitterMin':1,'jitterMax':3,'balance':{'concurrency':1,'enabled':False,'interval':3600,'positiveInterval':21600,'route':'direct'},'checkin':{'concurrency':1,'enabled':False,'interval':86400,'route':'direct'},'credential':{'concurrency':1,'enabled':False,'interval':3600,'route':'direct'}} for p in sorted(SUPPORTED)}} +def settings(): + with db() as c:r=c.execute("select value from settings where key='config'").fetchone() + return json.loads(r[0]) if r else json.loads(dumps(DEFAULT)) +def save_settings(v): + old=settings();out=json.loads(dumps(old)) + if 'paused' in v:out['paused']=bool(v['paused']) + for k,lo,hi in [('globalConcurrency',1,32),('globalBalanceConcurrency',1,16),('timeout',10,120),('queueBufferMultiplier',1,5)]: + if k in v:out[k]=max(lo,min(hi,int(v[k]))) + for p,d in v.get('providers',{}).items(): + if p not in SUPPORTED:continue + t=out['providers'][p] + for k,lo,hi in [('concurrency',1,20),('jitterMin',0,60),('jitterMax',0,120)]: + if k in d:t[k]=max(lo,min(hi,int(d[k]))) + t['jitterMax']=max(t['jitterMax'],t['jitterMin']) + for a in ['balance','checkin','credential']: + if a not in d:continue + x=d[a];r=t[a] + if 'enabled' in x:r['enabled']=bool(x['enabled']) + if 'paused' in x:r['paused']=bool(x['paused']) + if 'concurrency' in x:r['concurrency']=max(1,min({'balance':16,'checkin':4,'credential':2}[a],int(x['concurrency']))) + if 'interval' in x:r['interval']=max(300,min(604800,int(x['interval']))) + if 'positiveInterval' in x:r['positiveInterval']=max(300,min(604800,int(x['positiveInterval']))) + if 'route' in x: + route=str(x['route'])[:80] + if route not in ('auto','direct') and route!=r.get('route'): + with db() as c:found=c.execute('select 1 from proxies where id=? and enabled=1',(route,)).fetchone() + if not found:raise ValueError('固定节点不存在或已停用') + r['route']=route + if a=='checkin': + for k,default in [('start','00:10'),('end','23:00')]: + if k in x: + value=str(x[k]) + if not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d',value):raise ValueError('签到时间格式应为 HH:MM') + r[k]=value + if r.get('start','00:10')>=r.get('end','23:00'):raise ValueError('签到结束时间必须晚于开始时间') + if 'maxAttempts' in x:r['maxAttempts']=max(1,min(6,int(x['maxAttempts']))) + if 'retryInterval' in x:r['retryInterval']=max(300,min(21600,int(x['retryInterval']))) + with db() as c:c.execute('insert or replace into settings values(?,?)',('config',dumps(out))) + return out +def event(k,msg): + with db() as c:c.execute('insert into events values(?,?,?)',(now(),k,scrub(msg))) +class Official: + def __init__(self):self.lock=threading.Lock();self.op=urllib.request.build_opener(urllib.request.ProxyHandler({}),urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()));self.logged=False + def raw(self,path,body=None): + req=urllib.request.Request(GATEWAY+path,data=dumps(body).encode() if body is not None else None,headers={'Content-Type':'application/json'}) + with self.op.open(req,timeout=75) as r:v=json.load(r) + if isinstance(v,dict) and v.get('success') is False:raise RuntimeError('官方管理接口拒绝请求') + return v.get('data',v) if isinstance(v,dict) else v + def call(self,path,body=None): + with self.lock: + if not self.logged:self.raw('/api/panel/login',{'username':AUTH['username'],'password':AUTH['password']});self.logged=True + try:return self.raw(path,body) + except urllib.error.HTTPError as e: + if e.code not in (401,403):raise + self.logged=False;raise RuntimeError('官方管理登录失效,需要重试登录') +OFFICIAL=Official() +OFFICIAL_THREADS=threading.local() +def official_client(): + if not hasattr(OFFICIAL_THREADS,'client'):OFFICIAL_THREADS.client=Official() + return OFFICIAL_THREADS.client + +SNAP_BUILD_LOCK=threading.Lock() +def get_snapshot(force=False): + with SNAP_BUILD_LOCK:return build_snapshot(force) + +def build_snapshot(force=False): + with LOCK: + if not force and SNAP.get('expires',0)>time.time():return SNAP['data'] + with db(True,True) as c: + raw=c.execute('select a.id,a.provider,a.enabled,a.data,u.remaining,u.unlimited,u.last_success_at,u.last_attempt_at,u.error from accounts a left join account_usage_records u on a.id=u.account_id').fetchall() + signed={r[0]:r[1] for r in c.execute('select account_id,max(ts) from checkin_records where date=? and ok=1 group by account_id',(today(),))} + with db() as c: + cool={r['scope']:dict(r) for r in c.execute('select * from cooldowns where until>?',(now(),))} + latest={r['account']:dict(r) for r in c.execute('select j.* from jobs j join (select account,max(created) as m from jobs group by account) x on j.account=x.account and j.created=x.m')} + rows=[];needs=[] + for r in raw: + try:d=decode(r['data']) + except:continue + u=d.get('usageQuery'); + if r['provider'] in SUPPORTED and (not isinstance(u,dict) or u.get('enabled') is not False):needs.append(r['id']) + j=latest.get(r['id'],{});cd=max(cool.get('account:'+r['id'],{}).get('until',0),cool.get('provider:'+r['provider'],{}).get('until',0));required=bool(d.get('needsRelogin')) or j.get('status')=='needs_login' + rows.append({'id':r['id'],'name':str(d.get('name') or r['id'])[:100],'provider':r['provider'],'enabled':bool(r['enabled']),'balance':r['remaining'],'unlimited':bool(r['unlimited']),'lastSuccess':r['last_success_at'] or 0,'lastAttempt':r['last_attempt_at'] or 0,'error':scrub(r['error'] or ''),'signed':r['id'] in signed,'signedAt':signed.get(r['id'],0),'cooldownUntil':cd,'needsLogin':required,'supported':r['provider'] in SUPPORTED,'jobStatus':j.get('status',''),'jobMessage':j.get('message',''),'autoBalanceEnabled':r['id'] in needs,**credential_meta(r['provider'],secret_of(d))}) + result={'at':now(),'accounts':rows,'summary':{'total':len(rows),'positive':sum((r['balance'] or 0)>0 for r in rows),'knownBalance':sum(r['lastSuccess']>0 for r in rows),'signedToday':sum(r['signed'] for r in rows),'cooling':sum(r['cooldownUntil']>now() for r in rows),'needsLogin':sum(r['needsLogin'] for r in rows)},'autoBalanceIds':needs} + with LOCK:SNAP.update(data=result,expires=time.time()+6) + return result + +def select_proxy(route): + if route=='direct':return None + rows=HEALTH.candidates(route) + if not rows:raise ProxyUnavailable('MAINTENANCE_PROXY_WAIT: 无可用维护节点,保留排队;不会回退直连') + return random.choice(rows) + +class WriteConflict(Exception):pass +class RpcError(Exception): + def __init__(self,obj):self.obj=obj;super().__init__(dumps(obj)) +class Worker: + def __init__(self,aid,proxy,timeout): + self.aid=aid;self.row,self.initial=account(aid);self.timeout=timeout;self.q=queue.Queue();self.n=0;self.changed=0;self.temp=ROOT/'workers'/secrets.token_hex(10);self.temp.mkdir(parents=True,mode=0o700) + env=os.environ.copy() + for k in list(env): + if 'proxy' in k.lower():env.pop(k,None) + if proxy: + val=decode(proxy['secret']);u=urllib.parse.urlsplit(val['url']);host=u.hostname + if not re.fullmatch(r'[0-9.]+',host or ''):raise RuntimeError('第一版代理地址请填写 IPv4,避免解析歧义') + user=urllib.parse.unquote(u.username or '');pw=urllib.parse.unquote(u.password or '') + if any(ch.isspace() for ch in user+pw) or '\n' in user+pw:raise RuntimeError('代理凭证不能含空白字符') + typ={'http':'http','socks5':'socks5','socks4':'socks4'}.get(u.scheme) + if not typ or not u.port:raise RuntimeError('仅支持带端口的 HTTP/SOCKS5/SOCKS4 代理') + config='strict_chain\nquiet_mode\nproxy_dns\ntcp_read_time_out 15000\ntcp_connect_time_out 8000\n[ProxyList]\n'+f'{typ} {host} {u.port}'+(f' {user} {pw}' if user else '')+'\n' + cf=self.temp/'proxychains.conf';cf.write_text(config);cf.chmod(0o600);env['LD_PRELOAD']='/usr/lib/x86_64-linux-gnu/libproxychains.so.4';env['PROXYCHAINS_CONF_FILE']=str(cf) + self.proc=subprocess.Popen([PLUGIN],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True,bufsize=1,env=env,cwd=self.temp,start_new_session=True) + def read(): + try: + for line in self.proc.stdout: + try:self.q.put(json.loads(line)) + except:pass + finally:self.q.put({'_eof':True}) + threading.Thread(target=read,daemon=True).start() + try:self.rpc('init',{'env':{'data_dir':str(self.temp),'host_version':PLUGIN_HOST_VERSION,'target':PLUGIN_TARGET,'proxy':'','debug_traffic':False}},min(8,timeout)) + except:self.close();raise + def callback(self,m,p): + aid=p.get('account',self.aid) + if m.startswith('store.') and aid and aid!=self.aid:raise RuntimeError('插件越过当前账户范围') + if m=='store.get': + r,d=account(self.aid);return {'id':self.aid,'provider':r['provider'],'secret':secret_of(d),'public':d.get('pluginPublic',{})} + if m=='store.list':return [self.callback('store.get',{'account':self.aid})] + if m=='store.update': + with db(True) as c: + c.execute('begin immediate');r=c.execute('select data from accounts where id=?',(aid,)).fetchone() + if not r:return {'updated':False} + d=decode(r[0]);cur=secret_of(d) + if cur!=p.get('expect'):return {'updated':False} + if 'pluginSecret' in d:d['pluginSecret']=p['secret'] + else: + for k in cur:d.pop(k,None) + d.update(p['secret']) + if isinstance(p.get('public'),dict):d['pluginPublic']=p['public'] + d['updatedAt']=now();n=c.execute('update accounts set data=? where id=? and data=?',(encode(d),aid,r[0])).rowcount;self.changed+=n;return {'updated':bool(n)} + if m in ['store.ledger_get','store.ledger_put','settings.get','settings.set']: + k=dumps([self.row['provider'],aid,p.get('kind'),p.get('key')]) + with db() as c: + if m.endswith(('get','.get')): + r=c.execute('select value from ledger where key=?',(k,)).fetchone();return decode(r[0]).get('value') if r else None + c.execute('insert or replace into ledger values(?,?)',(k,encode({'value':p.get('value')})));return None + if m in ['log','notify']:return None + raise RuntimeError('第一版未支持此官方回调:'+m) + def rpc(self,m,p,timeout=None): + self.n+=1;n=self.n;self.proc.stdin.write(dumps({'id':n,'method':m,'params':p})+'\n');self.proc.stdin.flush();end=time.monotonic()+(timeout or self.timeout) + while time.monotonic()0:sec=max(sec,retry) + cooldown('account:'+aid,sec,msg) + if '429' in str(e):cooldown('provider:'+p,sec,msg) + if a=='checkin': + try:write_checkin(aid,{'outcome':'error','error':msg,'message':'请求未得到成功确认;上游是否已执行需核对'}) + except Exception:pass + finally: + if w:w.close() + with db() as c: + if status=='queued':c.execute("update jobs set status='queued',started=null,ended=null,message=? where id=?",(msg,job['id'])) + else:c.execute('update jobs set status=?,ended=?,message=? where id=?',(status,now(),msg,job['id'])) + with LOCK:ACTIVE.pop(aid,None);SNAP.clear() + +def queue_post_checkin_balance(aid,provider,required_after): + # The account is still reserved in ACTIVE until this transaction finishes. + with LOCK,db() as c: + c.execute('begin immediate') + existing=c.execute("select id,status from jobs where account=? and action='balance' and status in ('queued','running') order by created,id",(aid,)).fetchone() + if existing: + c.execute("update jobs set origin='post_checkin',priority=100,required_after=max(required_after,?),message='签到成功,优先刷新余额' where id=?",(required_after,existing['id'])) + return existing['id'] + jid=secrets.token_hex(12) + c.execute("insert into jobs(id,account,provider,action,status,created,message,attempt,origin,priority,required_after) values(?,?,?,'balance','queued',?,'签到成功,优先刷新余额',0,'post_checkin',100,?)",(jid,aid,provider,required_after,required_after)) + return jid + +def enqueue(ids,action,skip_positive=False,verify_only=False,origin='manual'): + if action not in ['balance','checkin','credential']:raise ValueError('未知任务') + if verify_only and action!='checkin':raise ValueError('只读账单核对仅用于签到') + rows={r['id']:r for r in get_snapshot()['accounts']};added=0;skipped=0 + with LOCK,db() as c: + c.execute('begin immediate') + for aid in dict.fromkeys(ids): + r=rows.get(aid) + if not r or (verify_only and r['provider']!='raccoon') or not r['supported'] or (r['needsLogin'] and action!='credential') or r['cooldownUntil']>now() or (skip_positive and (r['balance'] or 0)>0) or (action=='checkin' and r['signed']) or (action=='credential' and not r.get('credentialRenewable')):skipped+=1;continue + if c.execute("select 1 from jobs where account=? and action=? and status in ('queued','running')",(aid,action)).fetchone():skipped+=1;continue + c.execute('insert into jobs(id,account,provider,action,status,created,message,attempt,origin) values(?,?,?,?,?,?,?,?,?)',(secrets.token_hex(12),aid,r['provider'],action,'queued',now(),'账单核对(不重复领取)' if verify_only else '等待执行',-1 if verify_only else 0,origin));added+=1 + return {'added':added,'skipped':skipped} + +def auto_enqueue(): + cfg=settings() + if cfg['paused']:return + snap=get_snapshot();t=now() + with db() as c: + pending_rows=c.execute("select account,action,provider from jobs where status in ('queued','running')").fetchall() + pending={(r[0],r[1]) for r in pending_rows} + pending_load=collections.Counter((r[2],r[1]) for r in pending_rows) + last={(r[0],r[1]):r[2] for r in c.execute('select account,action,max(created) from jobs group by account,action')} + day_start=int(datetime.datetime.now(TZ).replace(hour=0,minute=0,second=0,microsecond=0).timestamp()*1000) + attempts={r[0]:r[1] for r in c.execute("select account,count(*) from jobs where action='checkin' and attempt>=0 and created>=? and status not in ('cancelled','skipped') group by account",(day_start,))} + for a in ['credential','checkin','balance']: + candidates=collections.defaultdict(list) + for r in snap['accounts']: + if not r['supported'] or (r['needsLogin'] and a!='credential') or r['cooldownUntil']>t or (r['id'],a) in pending:continue + cfgp=cfg['providers'][r['provider']][a] + if not cfgp['enabled'] or cfgp.get('paused',False):continue + prev=last.get((r['id'],a),0) + if a=='credential': + if credential_due(r,prev,t,cfgp):candidates[r['provider']].append((r.get('credentialExpiresAt') or 2**62,prev,r['id'])) + continue + interval=cfgp['interval'] + if a=='balance': + if balance_waits_for_checkin(r):continue + interval=balance_interval(r,cfgp);prev=max(prev,r['lastAttempt'],r['lastSuccess']) + if a=='checkin': + clock=datetime.datetime.now(TZ).strftime('%H:%M') + if r['signed'] or not cfgp.get('start','00:00')<=clock=cfgp.get('maxAttempts',3):continue + interval=cfgp.get('retryInterval',1800) + if prevr['lastSuccess']) or t-prev>=interval*1000:candidates[r['provider']].append((0 if a=='balance' and (r.get('balance') or 0)>0 else 1,prev,r['id'])) + # Separate queue budgets prevent balance backlog from blocking daily/renewal work. + for provider,items in candidates.items(): + cap=cfg['providers'][provider][a].get('concurrency',1) + count=max(0,cap*cfg.get('queueBufferMultiplier',3)-pending_load[(provider,a)]) + if count:enqueue([item[2] for item in sorted(items)[:count]],a,origin='auto') + +def dispatch_jobs(cfg,pool): + routes=HEALTH.available_routes() + snap={r['id']:r for r in get_snapshot()['accounts']};timestamp=now() + with LOCK,db() as c: + active=collections.Counter(v[0] for v in ACTIVE.values());active_action=collections.Counter(ACTIVE.values());balance_active=sum(v[1]=='balance' for v in ACTIVE.values());cap=cfg['globalConcurrency']-len(ACTIVE) + if cap<=0:return + # Rank separately per platform AND operation; a balance backlog cannot hide other work. + jobs=[dict(j) for j in c.execute("select * from (select j.*,row_number() over(partition by provider,action order by priority desc,created,id) as dispatch_rank from jobs j where status='queued')")] + for j in sorted(jobs,key=lambda j:queue_order(j,snap)): + aid=j['account'];p=j['provider'];a=j['action'];r=snap.get(aid) + if not r: + c.execute("update jobs set status='skipped',ended=?,message='账户已不存在' where id=?",(timestamp,j['id']));continue + if aid in ACTIVE or cfg['providers'][p][a].get('paused',False):continue + if a=='balance' and j.get('origin')=='auto' and balance_waits_for_checkin(r): + c.execute("update jobs set status='skipped',ended=?,message='已确认零积分,等待下次签到成功后查询' where id=?",(timestamp,j['id']));continue + if a=='balance' and balance_satisfied(j,r,timestamp,cfg['providers'][p]['balance']): + c.execute("update jobs set status='skipped',ended=?,message='已有更新的成功余额,本轮跳过' where id=?",(timestamp,j['id']));continue + if a=='checkin' and j['attempt']>=0: + clock=datetime.datetime.now(TZ).strftime('%H:%M');cp=cfg['providers'][p][a] + if not cp.get('start','00:00')<=clock?',('account:'+aid,'provider:'+p,timestamp)).fetchone() + if a=='balance' and balance_active>=cfg.get('globalBalanceConcurrency',16):continue + if cd or active[p]>=cfg['providers'][p]['concurrency'] or active_action[(p,a)]>=cfg['providers'][p][a].get('concurrency',1):continue + c.execute("update jobs set status='running',started=? where id=? and status='queued'",(timestamp,j['id']));c.commit();ACTIVE[aid]=(p,a);active[p]+=1;active_action[(p,a)]+=1;pool.submit(execute,j);cap-=1 + if a=='balance':balance_active+=1 + if cap<=0:break + +def scheduler(): + pool=concurrent.futures.ThreadPoolExecutor(max_workers=32);last_auto=0 + while not STOP.wait(1): + try: + cfg=settings() + if cfg['paused']:continue + if time.time()-last_auto>10:auto_enqueue();last_auto=time.time() + dispatch_jobs(cfg,pool) + except Exception as e:event('scheduler',type(e).__name__+': '+scrub(e)) + pool.shutdown(wait=True) + +def routing_observer(): + while not STOP.is_set(): + try: + response=official_client().call('/api/routing/queue');entries={} + for bucket in ['ordered','blocked']: + for row in response.get(bucket,[]): + aid=row.get('id') + if aid:entries[aid]={'state':str(row.get('state','unknown')),'reason':scrub(row.get('reason','')),'bucket':bucket,'resetAt':row.get('resetAt')} + with ROUTING_LOCK:ROUTING.update(at=now(),entries=entries,error='',strategy=response.get('strategy'),scope='未指定模型的官方调度队列') + except Exception as e: + with ROUTING_LOCK:ROUTING['error']='官方调度快照读取失败:'+scrub(type(e).__name__) + if STOP.wait(30):break + +def routing_snapshot(): + with ROUTING_LOCK:return dict(ROUTING,entries=dict(ROUTING['entries'])) + +def ownership_guard(): + # Only official settings APIs are used; credentials and proxy bindings are not changed. + while not STOP.wait(10): + try: + snap=get_snapshot(True);ids=snap['autoBalanceIds'] + if ids:OFFICIAL.call('/api/accounts/batch',{'action':'usageQuery','ids':ids,'usageQuery':{'enabled':False,'interval':0}});event('ownership',f'关闭 {len(ids)} 个新增/变更账户的官方自动余额查询') + except Exception as e:event('ownership','自动余额接管检查失败:'+type(e).__name__) + +def bridge_usage(params): + aid=str(params.get('account',''));provider=str(params.get('provider','')) + if provider not in SUPPORTED:raise ValueError('该平台未启用余额桥') + row,_=account(aid) + if row['provider']!=provider:raise ValueError('账户平台不匹配') + cfg=settings();pc=cfg['providers'][provider] + with LOCK: + if aid in BRIDGE_BUSY:raise RuntimeError('该账户余额查询正在执行') + if len(BRIDGE_BUSY)>=cfg['globalConcurrency'] or sum(p==provider for p in BRIDGE_BUSY.values())>=min(pc['concurrency'],pc['balance'].get('concurrency',1)):raise RuntimeError('余额桥并发已满,请排队重试') + BRIDGE_BUSY[aid]=provider + w=None;start=now();label='直连';status='error' + try: + with db() as c: + cd=c.execute('select 1 from cooldowns where scope in (?,?) and until>?',('account:'+aid,'provider:'+provider,now())).fetchone() + if cd:raise RuntimeError('账户或平台处于维护冷却期') + px=select_proxy(pc['balance']['route']);label=px['label'] if px else '直连' + with db() as c:c.execute("update jobs set proxy=? where account=? and action='balance' and status='running'",(label,aid)) + w=Worker(aid,px,min(cfg['timeout'],45));result=w.rpc('usage.query',{'provider':provider,'account':aid});status='ok' + return result + finally: + if w:w.close() + with db() as c:c.execute('insert into bridge_audit values(?,?,?,?,?,?)',(start,aid,provider,label,status,now()-start)) + with LOCK:BRIDGE_BUSY.pop(aid,None) + +with db() as c: + c.execute('create index if not exists jobs_status_queue on jobs(status,provider,action,created)') + c.execute('create index if not exists jobs_created on jobs(created desc)') +HEALTH=AutoProxyHealth(db,decode,settings) +UI_LOCK=threading.Lock();UI_WAKE=threading.Event();UI_STATE={'data':None,'error':''} +def build_ui_state(): + snap=enrich(scope_snapshot(get_snapshot()),settings(),db,now(),TZ,routing_snapshot()) + return {**snap,'config':settings(),'cacheSync':'余额返回官方主程序落库并更新调度缓存','supported':sorted(SUPPORTED),'builtAt':now()} +def ui_observer(): + while not STOP.is_set(): + try: + data=build_ui_state() + with UI_LOCK:UI_STATE.update(data=data,error='') + except Exception as e: + with UI_LOCK:UI_STATE['error']='维护视图更新失败:'+type(e).__name__ + UI_WAKE.wait(5);UI_WAKE.clear() +def ui_state(): + with UI_LOCK: + data=UI_STATE['data'];error=UI_STATE['error'] + if data is None:return None + return {**data,'viewError':error,'viewAgeSeconds':max(0,(now()-data['builtAt'])//1000)} + +class Handler(BaseHTTPRequestHandler): + server_version='Maintenance/1.0' + def log_message(self,*args):pass + def send(self,status,data,html=False,cookie=None,content_type=None): + body=data.encode() if html else dumps(data).encode();compressed='gzip' in self.headers.get('Accept-Encoding','') and len(body)>1024 + if compressed:body=gzip.compress(body,compresslevel=3) + self.send_response(status) + if compressed:self.send_header('Content-Encoding','gzip');self.send_header('Vary','Accept-Encoding') + self.send_header('Content-Type',content_type or ('text/html; charset=utf-8' if html else 'application/json; charset=utf-8'));self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff');self.send_header('X-Frame-Options','DENY');self.send_header('Referrer-Policy','no-referrer');self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'") + if cookie:self.send_header('Set-Cookie',cookie) + self.end_headers();self.wfile.write(body) + def authed(self): + try:c=http.cookies.SimpleCookie(self.headers.get('Cookie',''));token=c['maintenance_session'].value;return SESSIONS.get(token,0)>time.time() + except:return False + def do_GET(self): + try: + path=urllib.parse.urlsplit(self.path).path + if path=='/health':return self.send(200,{'ok':True,'version':'1.0','engine':'official-plugin-3.4.2'}) + if path=='/':return self.send(200,pathlib.Path('/app/index.html').read_text(),True) + if path=='/ui.js':return self.send(200,pathlib.Path('/app/ui.js').read_text(),html=True,content_type='application/javascript; charset=utf-8') + static={'/ux.js':('ux.js','application/javascript'),'/ui.css':('ui.css','text/css'),'/vendor/tom-select.min.js':('vendor/tom-select.min.js','application/javascript'),'/vendor/tom-select.css':('vendor/tom-select.css','text/css')} + if path in static: + f,mime=static[path];return self.send(200,(pathlib.Path('/app')/f).read_text(),html=True,content_type=mime+'; charset=utf-8') + if not self.authed():return self.send(401,{'error':'请登录'}) + if path in ['/api/state','/api/accounts','/api/queue','/api/history','/api/proxies']: + query=urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) + if path=='/api/proxies':return self.send(200,{'items':HEALTH.rows(),'health':HEALTH.status()}) + snap=ui_state() + if snap is None:return self.send(503,{'error':'首次维护快照正在加载,请稍候'}) + if path=='/api/state': + snap['config']=settings() + if query.get('compact')==['1']:snap={k:v for k,v in snap.items() if k!='accounts'} + return self.send(200,{**snap,'proxies':HEALTH.rows(),'proxyHealth':HEALTH.status()}) + if path=='/api/accounts':return self.send(200,account_page(snap,query)) + if path=='/api/queue':return self.send(200,queue_view(snap,settings(),db,now(),query,HEALTH.available_routes())) + result=history_view(db,query);names={r['id']:r['name'] for r in snap['accounts']} + for item in result['items']:item['name']=names.get(item['account'],item['account']) + return self.send(200,result) + return self.send(404,{'error':'不存在'}) + except (ValueError,KeyError) as e:self.send(400,{'error':scrub(e)}) + except (BrokenPipeError,ConnectionResetError):pass + except Exception as e:self.send(500,{'error':scrub(type(e).__name__+': '+str(e))}) + def do_POST(self): + try: + origin=self.headers.get('Origin');host=self.headers.get('Host') + if origin and urllib.parse.urlsplit(origin).netloc!=host:return self.send(403,{'error':'来源不匹配'}) + if self.headers.get('Content-Type','').split(';')[0]!='application/json':return self.send(415,{'error':'需要 JSON'}) + n=int(self.headers.get('Content-Length','0')) + if n>1000000:return self.send(413,{'error':'请求过大'}) + d=json.loads(self.rfile.read(n) or b'{}');path=urllib.parse.urlsplit(self.path).path + if path=='/internal/usage': + token=self.headers.get('Authorization','') + if not hmac.compare_digest(token.encode(),('Bearer '+BRIDGE_KEY).encode()):return self.send(401,{'error':{'message':'未授权','code':'unauthorized'}}) + try:return self.send(200,{'result':bridge_usage(d)}) + except RpcError as e:return self.send(200,{'error':{'message':scrub(e.obj.get('message',str(e))),'code':e.obj.get('code','upstream')}}) + except Exception as e:return self.send(200,{'error':{'message':scrub(e),'code':'maintenance_bridge'}}) + if path=='/api/login': + ip=self.client_address[0];attempts=[x for x in LOGIN_ATTEMPTS.get(ip,[]) if x>time.time()-300] + if len(attempts)>=10:return self.send(429,{'error':'登录尝试过多,等待5分钟'}) + if not (hmac.compare_digest(str(d.get('username','')).encode(),AUTH['username'].encode()) and hmac.compare_digest(str(d.get('password','')).encode(),AUTH['password'].encode())): + LOGIN_ATTEMPTS[ip]=attempts+[time.time()];return self.send(401,{'error':'用户名或密码错误'}) + token=secrets.token_urlsafe(32);SESSIONS[token]=time.time()+28800;return self.send(200,{'ok':True},cookie='maintenance_session='+token+'; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800') + if not self.authed():return self.send(401,{'error':'请登录'}) + if path=='/api/logout': + cookies=http.cookies.SimpleCookie(self.headers.get('Cookie',''));SESSIONS.pop(cookies['maintenance_session'].value,None) + return self.send(200,{'ok':True},cookie='maintenance_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0') + if path=='/api/settings': + result=save_settings(d);UI_WAKE.set();return self.send(200,result) + if path=='/api/proxies/health-settings':return self.send(200,HEALTH.save_policy(d)) + if path=='/api/proxies/test':return self.send(202,HEALTH.start(d.get('ids'))) + if path=='/api/proxies/toggle':return self.send(200,HEALTH.toggle(str(d.get('id','')),d.get('enabled'))) + if path=='/api/jobs': + ids=d.get('ids',[]) + if not isinstance(ids,list) or len(ids)>100:return self.send(400,{'error':'第一版每次最多选择100个,避免误点全量'}) + return self.send(200,enqueue(ids,d.get('action'),bool(d.get('skipPositive')),bool(d.get('verifyOnly')))) + if path=='/api/jobs/cancel': + ids=d.get('jobIds') + if not isinstance(ids,list) or not ids or len(ids)>100 or any(not isinstance(x,str) for x in ids):raise ValueError('请选择1—100条待执行任务;不取消执行中的请求') + with LOCK,db() as c: + n=c.execute("update jobs set status='cancelled',ended=?,message='用户取消待执行任务' where status='queued' and id in ("+','.join('?' for _ in ids)+')',(now(),*ids)).rowcount + return self.send(200,{'cancelled':n}) + if path=='/api/proxies': + url=str(d.get('url',''));u=urllib.parse.urlsplit(url) + if u.scheme not in ['http','socks5','socks4'] or not re.fullmatch(r'[0-9.]+',u.hostname or '') or not u.port:raise ValueError('请输入 http://IPv4:端口 或 socks5://IPv4:端口,可含用户名密码') + if any(x.isspace() for x in urllib.parse.unquote(u.username or '')+urllib.parse.unquote(u.password or '')):raise ValueError('代理凭证不支持空白字符') + pid=secrets.token_hex(8) + with db() as c:c.execute('insert into proxies values(?,?,?,1)',(pid,str(d.get('label') or '节点 '+pid[:4])[:60],encode({'url':url}))) + return self.send(200,{'id':pid,'ok':True}) + if path=='/api/proxies/delete': + return self.send(200,HEALTH.delete(str(d.get('id','')))) + return self.send(404,{'error':'不存在'}) + except (ValueError,KeyError) as e:self.send(400,{'error':scrub(e)}) + except Exception as e:self.send(500,{'error':scrub(type(e).__name__+': '+str(e))}) + +if __name__=='__main__': + signal.signal(signal.SIGTERM,lambda *_:STOP.set()) + threading.Thread(target=HEALTH.run,args=(STOP,),daemon=True).start();threading.Thread(target=ui_observer,daemon=True).start();threading.Thread(target=routing_observer,daemon=True).start();threading.Thread(target=scheduler,daemon=True).start();threading.Thread(target=ownership_guard,daemon=True).start() + server=ThreadingHTTPServer(('0.0.0.0',8080),Handler);server.timeout=1 + print('Maintenance v1 ready; official plugin; automatic tasks paused by default',flush=True) + while not STOP.is_set():server.handle_request() + server.server_close() diff --git a/contrib/maintenance-sidecar/tests/boot_smoke.py b/contrib/maintenance-sidecar/tests/boot_smoke.py new file mode 100644 index 000000000..79f30f07a --- /dev/null +++ b/contrib/maintenance-sidecar/tests/boot_smoke.py @@ -0,0 +1,17 @@ +import pathlib,os,json,sqlite3,time,sys,threading,urllib.request,http.cookiejar +for p in ['/state','/gateway','/key','/secrets']:pathlib.Path(p).mkdir(exist_ok=True) +pathlib.Path('/key/account.key').write_bytes(os.urandom(32));pathlib.Path('/secrets/auth.json').write_text(json.dumps({'username':'fixture','password':'fixture'}));pathlib.Path('/secrets/bridge.key').write_text('fixture') +with sqlite3.connect('/gateway/agent2api.db') as c: + c.executescript('create table accounts(id text,provider text,enabled int,data text);create table account_usage_records(account_id text,remaining real,unlimited int,last_success_at int,last_attempt_at int,error text);create table checkin_records(account_id text,date text,ok int,ts int)') + for i,p in enumerate(['loomy','raccoon','codearts']): + c.execute('insert into accounts values(?,?,1,?)',(str(i),p,json.dumps({'name':'fixture','pluginSecret':{},'usageQuery':{'enabled':False}})));c.execute('insert into account_usage_records values(?,100,0,?,?,null)',(str(i),int(time.time()*1000),int(time.time()*1000))) +sys.path.insert(0,'/app');import server +state=server.build_ui_state();assert set(state['dashboard']['platforms'])=={'loomy','raccoon'};server.UI_STATE['data']=state +srv=server.ThreadingHTTPServer(('127.0.0.1',0),server.Handler);t=threading.Thread(target=srv.serve_forever,daemon=True);t.start();base='http://127.0.0.1:'+str(srv.server_port) +op=urllib.request.build_opener(urllib.request.ProxyHandler({}),urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) +for path,mime in [('/','text/html'),('/ui.js','javascript'),('/ux.js','javascript'),('/ui.css','text/css'),('/vendor/tom-select.min.js','javascript'),('/vendor/tom-select.css','text/css')]: + with op.open(base+path) as r:assert mime in r.headers['Content-Type'];assert len(r.read())>100 +op.open(urllib.request.Request(base+'/api/login',data=b'{"username":"fixture","password":"fixture"}',headers={'Content-Type':'application/json'})).read() +with op.open(base+'/api/state?compact=1') as r:s=json.load(r);assert 'accounts' not in s and s['summary']['total']==2 and not s['proxyHealth']['config']['enabled'] +with op.open(base+'/api/accounts?size=1') as r:s=json.load(r);assert len(s['items'])==1 +srv.shutdown();server.HEALTH.pool.shutdown();print(json.dumps({'full_module_boot':True,'static_routes':6,'compact_state':True,'accounts_pagination':True,'network':'none','real_accounts':0})) diff --git a/contrib/maintenance-sidecar/tests/test_policy.py b/contrib/maintenance-sidecar/tests/test_policy.py new file mode 100644 index 000000000..112a592f0 --- /dev/null +++ b/contrib/maintenance-sidecar/tests/test_policy.py @@ -0,0 +1,68 @@ +import ast, collections, contextlib, datetime, importlib, json, pathlib, sqlite3, sys, tempfile, threading, types + +root=pathlib.Path(sys.argv[1]).resolve() if len(sys.argv)>1 else pathlib.Path(__file__).resolve().parents[1];sys.path.insert(0,str(root)) +p=importlib.import_module('maintenance_policy');d=importlib.import_module('dashboard') +tree=ast.parse((root/'server.py').read_text(encoding='utf-8-sig')) +nodes={n.name:n for n in tree.body if isinstance(n,ast.FunctionDef)} +ts=1794067199000 # 2026-11-07 23:59:59 Asia/Shanghai +zero={'id':'zero','provider':'loomy','balance':0,'lastSuccess':ts-7200000,'signedAt':ts-8000000,'signed':True,'enabled':True,'supported':True,'needsLogin':False,'cooldownUntil':0,'lastAttempt':ts-7200000,'credentialExpiresAt':0} +positive={**zero,'id':'positive','balance':100} +unknown={**zero,'id':'unknown','balance':None,'lastSuccess':0,'lastAttempt':1} +new_sign={**zero,'id':'new_sign','signedAt':ts-1000} +cfg={'paused':False,'globalConcurrency':1,'globalBalanceConcurrency':1,'providers':{'loomy':{'concurrency':1,'balance':{'enabled':True,'concurrency':1,'interval':3600,'positiveInterval':3600,'route':'auto'},'checkin':{'enabled':False},'credential':{'enabled':False}}}} +old=not hasattr(p,'balance_waits_for_checkin') +if old: + jobs=[{'id':'z','account':'zero','action':'balance','origin':'auto','dispatch_rank':1,'created':1},{'id':'p','account':'positive','action':'balance','origin':'auto','dispatch_rank':2,'created':2}] + order=[j['account'] for j in sorted(jobs,key=lambda j:d.queue_order(j,{'zero':zero,'positive':positive}))] + assert order==['zero','positive'] + print(json.dumps({'positive_first':False,'confirmed_zero_periodic':True,'midnight_gate':False,'order':order,'real_requests':0}));sys.exit() + +assert p.balance_waits_for_checkin(zero) +assert p.balance_waits_for_checkin({**zero,'signed':False,'signedAt':0}) # midnight alone cannot reopen +assert not p.balance_waits_for_checkin(new_sign) +assert not p.balance_waits_for_checkin(positive) +assert not p.balance_waits_for_checkin(unknown) +assert not p.balance_waits_for_checkin({**zero,'unlimited':True}) +assert not p.balance_waits_for_checkin({**zero,'balance':-1}) +assert not p.balance_waits_for_checkin({**zero,'lastSuccess':0}) +assert not p.balance_satisfied({'origin':'auto'},new_sign,ts,cfg['providers']['loomy']['balance']) +assert p.balance_waits_for_checkin({**new_sign,'lastSuccess':ts}) # follow-up zero goes back to sleep +assert not p.balance_waits_for_checkin({**new_sign,'lastSuccess':ts,'balance':100}) +assert not p.balance_satisfied({'origin':'post_checkin','required_after':ts},zero,ts,cfg['providers']['loomy']['balance']) + +with tempfile.TemporaryDirectory() as tmp: + @contextlib.contextmanager + def db(): + c=sqlite3.connect(pathlib.Path(tmp)/'fixture.db');c.row_factory=sqlite3.Row + try:yield c;c.commit() + finally:c.close() + with db() as c:c.executescript('create table jobs(id text,account text,provider text,action text,status text,created int,started int,ended int,message text,attempt int default 0,origin text default "auto",priority int default 0,required_after int default 0); create table cooldowns(scope text,until int);') + rows=[zero,positive,unknown];added=[] + ns={'settings':lambda:cfg,'get_snapshot':lambda:{'accounts':rows},'now':lambda:ts,'db':db,'LOCK':threading.RLock(),'collections':collections,'datetime':datetime,'TZ':datetime.timezone(datetime.timedelta(hours=8)),'credential_due':p.credential_due,'balance_interval':p.balance_interval,'balance_waits_for_checkin':p.balance_waits_for_checkin,'enqueue':lambda ids,a,**kw:added.extend(ids)} + exec(compile(ast.Module(body=[nodes['auto_enqueue']],type_ignores=[]),'actual-auto','exec'),ns) + ns['auto_enqueue']();assert added==['positive','unknown'],added + rows=[{**zero,'signed':False,'signedAt':0}];added.clear();ns['now']=lambda:ts+2000;ns['auto_enqueue']();assert not added + rows=[new_sign];ns['auto_enqueue']();assert added==['new_sign'] + + # A positive account beyond the old 256-row SQL window must still be dispatched first. + rows=[zero,positive,unknown] + with db() as c: + for i in range(300):c.execute('insert into jobs(id,account,provider,action,status,created) values(?,?,"loomy","balance","queued",?)',(str(i),'zero',i)) + c.execute('insert into jobs(id,account,provider,action,status,created) values("positive","positive","loomy","balance","queued",1000)') + submitted=[];ns.update(now=lambda:ts,ACTIVE={},queue_order=d.queue_order,balance_satisfied=p.balance_satisfied,HEALTH=types.SimpleNamespace(available_routes=lambda:{'auto'}),execute=lambda j:None) + exec(compile(ast.Module(body=[nodes['dispatch_jobs']],type_ignores=[]),'actual-dispatch','exec'),ns) + ns['dispatch_jobs'](cfg,types.SimpleNamespace(submit=lambda f,j:submitted.append(j))) + assert len(submitted)==1 and submitted[0]['account']=='positive',submitted + with db() as c:c.execute('delete from jobs') + ns['ACTIVE'].clear();submitted.clear();rows=[zero] + with db() as c:c.execute('insert into jobs(id,account,provider,action,status,created) values("zero","zero","loomy","balance","queued",1)') + ns['dispatch_jobs'](cfg,types.SimpleNamespace(submit=lambda f,j:submitted.append(j))) + assert not submitted + with db() as c:assert c.execute('select status from jobs').fetchone()[0]=='skipped' + # Explicit manual requests and post-checkin confirmation are not blocked by the zero gate. + for origin in ['manual','post_checkin']: + with db() as c: + c.execute('delete from jobs');c.execute('insert into jobs(id,account,provider,action,status,created,origin,required_after) values("explicit","zero","loomy","balance","queued",1,?,?)',(origin,ts if origin=='post_checkin' else 0)) + ns['ACTIVE'].clear();submitted.clear();ns['dispatch_jobs'](cfg,types.SimpleNamespace(submit=lambda f,j:submitted.append(j)));assert len(submitted)==1 + +print(json.dumps({'positive_first':True,'confirmed_zero_periodic':False,'midnight_gate':True,'new_checkin_reopens':True,'zero_again_suspends':True,'unknown_and_manual_preserved':True,'positive_beyond_old_window':True,'stale_zero_queue_skipped':True,'real_requests':0})) diff --git a/contrib/maintenance-sidecar/tests/test_proxy_health.py b/contrib/maintenance-sidecar/tests/test_proxy_health.py new file mode 100644 index 000000000..19e04d75d --- /dev/null +++ b/contrib/maintenance-sidecar/tests/test_proxy_health.py @@ -0,0 +1,71 @@ +"""Exercise the actual health policy using temporary state; never probe the network.""" +import contextlib +import json +import pathlib +import sqlite3 +import sys +import tempfile +import time +from unittest.mock import patch + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +from proxy_health import AutoProxyHealth, ProxyUnavailable + + +with tempfile.TemporaryDirectory() as tmp: + @contextlib.contextmanager + def db(): + c = sqlite3.connect(pathlib.Path(tmp) / 'fixture.db', timeout=3) + c.row_factory = sqlite3.Row + try: + yield c + c.commit() + finally: + c.close() + + with db() as c: + c.executescript(''' + CREATE TABLE settings(key TEXT PRIMARY KEY,value TEXT); + CREATE TABLE proxies(id TEXT PRIMARY KEY,label TEXT,secret TEXT,enabled INT); + CREATE TABLE events(ts INT,kind TEXT,message TEXT); + ''') + for ident, enabled in [('a', 1), ('b', 1), ('manual-off', 0)]: + c.execute('INSERT INTO proxies VALUES(?,?,?,?)', (ident, ident, '{}', enabled)) + + config = {'providers': {}} + h = AutoProxyHealth(db, json.loads, lambda: config) + try: + assert not h.policy()['enabled'] + h.save_policy({'enabled': True}) + h.on_result('a', 'failed') + assert not next(x for x in h.rows() if x['id'] == 'a')['autoBlocked'] + h.on_result('a', 'timeout') + assert next(x for x in h.rows() if x['id'] == 'a')['autoBlocked'] + assert [x['id'] for x in h.candidates('auto')] == ['b'] + assert not h.candidates('a') + try: + h.require_available('a') + raise AssertionError('Quarantined fixed route was allowed') + except ProxyUnavailable: + pass + h.require_available('direct') + h.on_result('a', 'ok') + assert next(x for x in h.rows() if x['id'] == 'a')['autoBlocked'] + h.on_result('a', 'ok') + assert not next(x for x in h.rows() if x['id'] == 'a')['autoBlocked'] + h.toggle('a', False) + h.on_result('a', 'ok') + h.on_result('a', 'ok') + assert 'a' not in h.available_routes() # success never overrides manual off + timestamp = int(time.time() * 1000) + with patch.object(h, 'start', return_value={'queued': 1}) as start: + h.tick(timestamp) + with db() as c: + assert not c.execute("SELECT 1 FROM proxy_health_state WHERE id='manual-off'").fetchone() + c.execute("UPDATE proxy_health_state SET next_check_at=? WHERE id='b'", (timestamp - 1,)) + assert h.tick(timestamp) == 1 + assert start.call_args.args[0] == ['b'] + print(json.dumps({'quarantine': True, 'recovery': True, 'manual_off_preserved': True, + 'fixed_route_blocked': True, 'staggered_schedule': True, 'real_probes': 0})) + finally: + h.pool.shutdown(wait=True) diff --git a/contrib/maintenance-sidecar/ui.css b/contrib/maintenance-sidecar/ui.css new file mode 100644 index 000000000..e9b50d3cf --- /dev/null +++ b/contrib/maintenance-sidecar/ui.css @@ -0,0 +1,2 @@ +/* Shared, local-only design tokens. Tom Select is vendored; no CDN runtime. */ +:root{--ink:#202737;--muted:#6b7486;--primary:#6654c8;--line:#e5e8f0;--soft:#eeebfb;--radius:10px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}button,input,select,.ts-wrapper{font-family:inherit}button{min-height:34px;transition:background .13s,border-color .13s,box-shadow .13s;font-weight:500}button:hover{background:#f5f3fc;border-color:#c1b7e7}button.primary,button.primary:hover,button.primary:focus-visible{background:var(--primary);color:#fff;border-color:var(--primary)}button.primary:hover{background:#5541ba}button:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid #d9d2fa;outline-offset:2px}button:disabled{opacity:.5}button[aria-busy=true]{position:relative;pointer-events:none;opacity:.75}button[aria-busy=true]::before{content:"";width:12px;height:12px;display:inline-block;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite;margin-right:7px;vertical-align:-2px}.boot{min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:15px;color:var(--muted)}.spinner{display:inline-block;width:24px;height:24px;border:3px solid #e5dff9;border-top-color:var(--primary);border-radius:50%;animation:spin .8s linear infinite}.boot strong{font-size:18px;color:var(--ink)}.login-card{width:min(408px,100%);padding:36px;border-radius:18px;box-shadow:0 18px 65px #34305b08}.login-card input{min-height:44px}.login-card button{min-height:44px;display:flex;align-items:center;justify-content:center;gap:14px}.login-card .logo{width:42px;height:42px}.login-card h1{font-size:24px}.login-card p{line-height:1.7}.login-card label{font-weight:500;color:#505b70}.panel{border-radius:var(--radius)}.panel-head{background:#fff}.toolbar{align-items:center;gap:10px;flex-wrap:wrap}.toolbar>input[type=search]{flex:1;min-width:180px;max-width:360px}.table-wrap{min-height:120px}th{background:#f8f9fc;color:#657086;font-size:11px;letter-spacing:.2px}tbody tr:hover{background:#fafaff}td{padding-top:13px;padding-bottom:13px}td .name{max-width:250px}.empty{padding:44px 20px!important;text-align:center;color:var(--muted)}.empty b{display:block;font-weight:600;font-size:14px;color:#4c576d;margin-bottom:6px}.small-btn{font-size:11px;min-height:29px}.danger:hover{background:#fff0f2;border-color:#ebc5cc}.fetch-status{min-height:30px;display:flex;align-items:center;gap:7px;padding:5px 18px;color:var(--muted);font-size:11px;border-bottom:1px solid #f0f1f6}.fetch-status.loading::before{content:"";display:inline-block;width:10px;height:10px;border:2px solid #ddd6f5;border-top-color:var(--primary);border-radius:50%;animation:spin .7s linear infinite}.fetch-status.error{background:#fff3f4;color:#b04454}.fetch-status button{margin-left:auto;padding:0 8px;min-height:23px;font-size:11px}.table-wrap[aria-busy=true]{opacity:.72;transition:opacity .1s}.settings-footer{position:sticky;bottom:14px;z-index:6;margin-bottom:14px;background:#fffffff7;border:1px solid var(--line);padding:13px 17px;border-radius:10px}.setting-block{padding:20px;border-top:1px solid var(--line)}.setting-head{margin-bottom:14px}.forms label{font-size:12px;color:#596479}.forms input{min-height:36px}.forms label.check{background:#f8f9fc;border-radius:7px;padding:9px 10px;align-self:end;min-height:36px}.forms label.check input{min-height:0}.forms label .ts-wrapper{margin-top:6px}.ts-wrapper{font-size:13px;min-width:140px;display:block}.ts-wrapper.single .ts-control{padding:8px 32px 8px 11px;min-height:36px;box-shadow:none;border:1px solid #dfe2eb;border-radius:7px;background:#fff!important;color:var(--ink);cursor:pointer}.ts-control>input{font-size:13px!important;color:var(--ink);padding:0!important;min-height:0!important}.ts-wrapper.single.input-active .ts-control{background:#fff!important}.ts-wrapper.focus .ts-control{border-color:#aa9bdc;box-shadow:0 0 0 3px #ede8fc}.ts-wrapper.single .ts-control::after{border-color:#778196 transparent transparent}.ts-dropdown{z-index:200;font-size:13px;background:#fff;border:1px solid #e5e0f0;border-radius:9px;padding:5px;box-shadow:0 10px 35px #30215220;overflow:hidden}.ts-dropdown .option{padding:9px 10px;border-radius:5px;color:#344056}.ts-dropdown .active{background:#f0ecfc;color:#5f49b4}.ts-dropdown .selected{background:#f5f2fd;font-weight:600}.ts-dropdown .no-results{padding:12px;color:var(--muted)}.ts-dropdown-content{max-height:270px}.ts-wrapper .ts-control>.item{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.toolbar .ts-wrapper{min-width:140px;max-width:220px}.forms .ts-wrapper{width:100%}.forms .ts-control{width:100%}.proxy-add{display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap}.proxy-add label{display:flex;flex-direction:column;gap:7px;font-size:12px;color:var(--muted)}.proxy-add input{min-height:38px}.proxy-add .grow{flex:1;min-width:250px}.node-note{padding:0 20px 18px}.node-actions{display:flex;gap:6px}.latency{font-variant-numeric:tabular-nums;font-weight:600}.node-name{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}dialog{border:1px solid var(--line);border-radius:15px;width:min(420px,calc(100vw - 32px));padding:26px;box-shadow:0 24px 100px #1c143c33;color:var(--ink)}dialog::backdrop{background:#1d23365c;backdrop-filter:blur(2px)}dialog .dialog-icon{width:34px;height:34px;display:grid;place-items:center;color:var(--primary);background:var(--soft);border-radius:10px;font-weight:700;margin-bottom:16px}dialog h2{font-size:18px}dialog p{margin:12px 0 24px;color:var(--muted);line-height:1.8}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.sidebar{z-index:10}.nav button{min-height:42px}.nav button.active{box-shadow:inset 3px 0 var(--primary)}.toast{font-size:13px}.footer-note{line-height:1.8}button[data-tab]{transition:none}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:760px){.toolbar .ts-wrapper{flex:1;min-width:128px;max-width:none}.toolbar input[type=search]{max-width:none;width:100%;flex-basis:100%}.proxy-add{display:grid;grid-template-columns:1fr}.proxy-add .grow{min-width:0}.proxy-add button{width:100%}.heading p{max-width:95%}.nav button{min-height:36px}.nav button.active{box-shadow:inset 0 -2px var(--primary)}.nav{scrollbar-width:none}.login-card{padding:28px}.settings-footer{bottom:8px}.topbar .controls{gap:5px}.topbar button{padding:6px 9px;font-size:11px}.ts-dropdown{max-width:calc(100vw - 24px)}.fetch-status{padding-left:12px}.forms{grid-template-columns:1fr}.kpi-value{font-size:24px}.points{font-size:25px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}} diff --git a/contrib/maintenance-sidecar/ui.js b/contrib/maintenance-sidecar/ui.js new file mode 100644 index 000000000..d497c61a4 --- /dev/null +++ b/contrib/maintenance-sidecar/ui.js @@ -0,0 +1,16 @@ +'use strict';let state=null,tab='overview',loading=false,dirty=false,ap=0,qp=0,hp=0,qdata=null,hdata=null,selected=new Set(),visible=[];const $=id=>document.getElementById(id),esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),fmt=x=>x===null||x===undefined?'—':Number(x).toLocaleString('zh-CN',{maximumFractionDigits:2}),clock=x=>x?new Date(x).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false,timeZone:'Asia/Shanghai'}):'尚无记录',platform=p=>({raccoon:'小浣熊',loomy:'Loomy'}[p]||p),action=a=>({balance:'余额查询',checkin:'签到',credential:'凭证维护'}[a]||a),titles={overview:'运行总览',accounts:'账户与积分',queue:'实时队列',history:'执行记录',settings:'调度设置',proxies:'代理节点'},labels={usable:'官方可调度',limiter:'余额 / 限额拦截',concurrency:'并发占满',absent:'未进官方队列',unknown:'未知',ok:'成功',error:'失败',cooling:'冷却',needs_login:'需重新登录',queued:'排队中',running:'执行中',waiting:'未到期',awaiting_checkin:'零积分 · 等待下次签到',due:'已到期待入队',paused:'已暂停',disabled:'未启用',unsupported:'不支持',unconfirmed:'入账未确认',skipped:'已跳过',cancelled:'已取消',interrupted:'已中断',signed:'签到完成',exhausted:'今日次数用完',retry_wait:'等待重试',outside_window:'窗口外',eligible:'待安排'},origin=o=>({post_checkin:'签到后补查',auto:'自动排队',manual:'手动任务'}[o]||'历史任务'),pill=s=>`${esc(labels[s]||s||'—')}`,duration=s=>s<60?`${Math.floor(s)}秒`:`${Math.floor(s/60)}分${Math.floor(s%60)}秒`; +function toast(s){$('toast').textContent=s;$('toast').classList.remove('hidden');clearTimeout(window.toastTimer);window.toastTimer=setTimeout(()=>$('toast').classList.add('hidden'),5000)} +function pending(p,a,s){return state.dashboard.pending.filter(x=>(!p||x.provider===p)&&(!a||x.action===a)&&(!s||x.status===s)).reduce((n,x)=>n+x.attempts,0)} +function kpi(label,value,note,color=''){return `
${label}
${fmt(value)}
${note}
`} +function renderOverview(){const d=state.dashboard,ps=Object.values(d.platforms),running=pending('','','running'),queued=pending('','','queued');$('live-indicator').textContent=state.config.paused?'调度已暂停':'调度运行中';$('live-indicator').className='pill '+(state.config.paused?'warn':'good');$('global-pause').textContent=state.config.paused?'恢复调度':'暂停调度';$('nav-queue').textContent=fmt(queued);$('date-label').textContent=d.date+' · 北京时间';$('last-updated').textContent='账户快照 '+clock(state.at)+' · 官方调度 '+clock(d.routing.at);$('overview-kpis').innerHTML=kpi('接入账户',state.summary.total,'主程序账户实时读取')+kpi('官方当前可调度',d.routingFresh?ps.reduce((n,p)=>n+(p.routable||0),0):null,'未指定模型 · 不等于调用成功','good')+kpi('维护执行中',running,`总并发 ${state.config.globalConcurrency} · 余额上限 ${state.config.globalBalanceConcurrency||16}`,'purple')+kpi('等待执行',queued,'同账户同操作不重复排队'); +$('platform-cards').innerHTML=Object.entries(d.platforms).map(([p,x])=>`
${p==='raccoon'?'R':'L'}${platform(p)}
可调度 ${fmt(x.routable)} / ${fmt(x.total)}
可调度账户 · 已知正余额合计
${fmt(x.routablePoints)} 积分
全部账户已知正余额 ${fmt(x.positivePoints)}未知 ${fmt(x.balance.unknown||0)} · 不限量 ${fmt(x.balance.unlimited||0)}
${fmt(x.balance.positive||0)}有正余额
${fmt(x.fresh)}余额在刷新周期内
${fmt(x.needsLogin)}需重新登录
到期待入队 ${fmt(x.due)} · 零积分待签到 ${fmt(x.balanceStates.awaiting_checkin||0)} · 维护冷却 ${fmt(x.cooling)}可调度但余额未知 ${fmt(x.routableUnknown)}
`).join(''); +$('routing-note').textContent=d.routingFresh?'可调度状态来自官方调度队列,会随并发、限制器与模型条件变化。积分是最近成功查询值,不是实时扣费余额;不同平台不合计。':'官方调度快照不可用或已过期,可调度数量显示“—”,不会用有余额账户数代替。'+(d.routing.error||'');$('routing-note').className='notice'+(d.routingFresh?'':' warn'); +$('operation-rows').innerHTML=Object.entries(state.config.providers).flatMap(([p,pc])=>['balance','checkin','credential'].map(a=>{const cfg=pc[a],rate=d.throughput10m.find(x=>x.provider===p&&x.action===a)||{},disabled=a==='credential'&&p==='loomy';return `${platform(p)}${action(a)}${pending(p,a,'running')} / ${cfg.concurrency||1}${pending(p,a,'queued')}${fmt(rate.succeeded||0)} / ${fmt(rate.completed||0)}${a==='balance'?`每账户 ${Math.round(cfg.interval/60)} 分钟`:a==='checkin'?`${cfg.start||'00:00'}—${cfg.end||'23:00'}`:'临期优先 / 官方确认'}${cfg.paused?'队列暂停':cfg.enabled?'自动入队已开启':'自动入队关闭'}`})).join(''); +$('checkin-summary').innerHTML=Object.entries(d.platforms).map(([p,x])=>`
${platform(p)}成功 ${fmt(x.signed)} / ${fmt(x.total)}
未完成 ${fmt(x.unsigned)} · 次数用完 ${fmt(x.checkin.exhausted||0)}排队 ${pending(p,'checkin','queued')}
`).join('')+'

签到成功优先安排余额查询;未确认入账不算成功。

'; +$('credential-summary').innerHTML=Object.entries(d.platforms).map(([p,x])=>`
${platform(p)}
支持续期 ${fmt(x.renewable)} 个未支持 ${fmt(x.total-x.renewable)} 个
`).join('')+'

到期前5分钟安排可续期凭证。IAM与Loomy不支持官方续期;签到、查余额不代表Token延期。

'; +$('queue-metrics').innerHTML=kpi('余额执行中',pending('','balance','running'),`共享 ${state.config.globalBalanceConcurrency||16} 并发`,'purple')+kpi('签到执行中',pending('','checkin','running'),'低并发 · 成功即跳过')+kpi('凭证维护中',pending('','credential','running'),'临期优先 · 不做无效续期')+kpi('等待执行',queued,`自动缓冲 ${state.config.queueBufferMultiplier||3} × 操作并发`); +} +function pager(prefix,page,total){$(prefix+'-page-info').textContent=`共 ${fmt(total)} 条 · 第 ${page+1} / ${Math.max(1,Math.ceil(total/50))} 页`;$(prefix+'-prev').disabled=page===0;$(prefix+'-next').disabled=(page+1)*50>=total} +function renderHistorySummary(){const d=state.dashboard;$('history-summary').innerHTML=d.todayUnique.map(g=>{const rows=d.todayAttempts.filter(x=>x.provider===g.provider&&x.action===g.action),ok=rows.find(x=>x.status==='ok')||{},failed=rows.filter(x=>['error','cooling','needs_login','interrupted','unconfirmed'].includes(x.status)).reduce((n,x)=>n+x.attempts,0);return `${platform(g.provider)} / ${action(g.action)}${fmt(ok.attempts||0)}${fmt(ok.accounts||0)}${fmt(failed)}${rows.filter(x=>!['ok','error','cooling','needs_login','interrupted','unconfirmed'].includes(x.status)).map(x=>(labels[x.status]||x.status)+' '+x.attempts).join(' · ')||'—'}`}).join('')||'今日尚无任务'} +function routeOptions(value){return [{id:'auto',label:'自动选择维护节点'},{id:'direct',label:'直连'},...state.proxies.map(p=>({...p,label:p.label+(!p.enabled?'(手动停用)':p.autoBlocked?'(自动隔离)':'')}))].map(p=>``).join('')} +function paintSettings(){for(const k of ['globalConcurrency','globalBalanceConcurrency','timeout'])$(k).value=state.config[k]||16;$('queueBufferMultiplier').value=state.config.queueBufferMultiplier||3;$('provider-settings').innerHTML=Object.entries(state.config.providers).map(([p,c])=>`

${platform(p)}

平台最多 ${c.concurrency} 个在途任务
${['balance','checkin','credential'].map(a=>`

${action(a)}

${c[a].paused?pill('paused'):pill(c[a].enabled?'running':'disabled')}
${a==='checkin'?``:``}${a==='balance'?``:''}

${a==='balance'?'有积分优先排队,每账户单独计时。确认零积分后退出周期查询,00:00不扫零余额;下次签到成功立即补查,有积分再恢复周期查询。手动刷新仍可用。':a==='checkin'?'当天成功即跳过;失败按间隔与冷却重试。关闭自动入队不取消已排队任务,暂停队列才会停止派发。':p==='loomy'?'官方不支持Loomy续期;不能把定时查询当成凭证延期。':'可续期账户进入到期前5分钟才安排,较早到期优先;IAM凭证不支持此续期接口。'}

`).join('')}
`).join('')} diff --git a/contrib/maintenance-sidecar/ui_support.py b/contrib/maintenance-sidecar/ui_support.py new file mode 100644 index 000000000..fcf559f8a --- /dev/null +++ b/contrib/maintenance-sidecar/ui_support.py @@ -0,0 +1,81 @@ +"""Read-only UI projections and bounded, explicit proxy health checks.""" +import datetime,json,os,pathlib,subprocess,tempfile,threading,time,urllib.parse,re,ipaddress,concurrent.futures +SUPPORTED={'loomy','raccoon'} +def scope_snapshot(snapshot): + rows=[x for x in snapshot['accounts'] if x['provider'] in SUPPORTED] + s=dict(snapshot);s['accounts']=rows;s.pop('autoBalanceIds',None) + s['summary']={'total':len(rows),'positive':sum((r.get('balance') or 0)>0 for r in rows),'knownBalance':sum(r.get('lastSuccess',0)>0 for r in rows),'signedToday':sum(r.get('signed',False) for r in rows),'cooling':sum(r.get('cooldownUntil',0)>s['at'] for r in rows),'needsLogin':sum(r.get('needsLogin',False) for r in rows)} + return s + +def account_page(state,query): + value=lambda k:query.get(k,[''])[0] + page=max(0,min(100000,int(value('page') or 0)));size=max(1,min(100,int(value('size') or 50))) + provider=value('provider');term=value('search').strip().casefold();f=value('filter');now=state['at'] + def match(r): + if provider and r['provider']!=provider:return False + if term and term not in (r['name']+' '+r['id']).casefold():return False + flags={'usable':r['routing']['state']=='usable','positive':r['balanceKind']=='positive','zero':r['balanceKind']=='zero','unknown':r['balanceKind']=='unknown','due':r['balanceDetail']['due'],'queued':r['balanceDetail']['state']=='queued','stale':r['lastSuccess']>0 and not r['balanceDetail']['fresh'],'needs_login':r['needsLogin'],'cooling':r['cooldownUntil']>now,'unsigned':not r['signed'],'renewable':r.get('credentialRenewable',False)} + return not f or flags.get(f,False) + rows=[r for r in state['accounts'] if match(r)];total=len(rows);page=min(page,max(0,(total-1)//size)) + return {'at':now,'total':total,'page':page,'size':size,'items':rows[page*size:(page+1)*size]} + +class ProxyHealth: + def __init__(self,db,decode,settings): + self.db=db;self.decode=decode;self.settings=settings;self.lock=threading.Lock();self.active=set();self.pool=concurrent.futures.ThreadPoolExecutor(max_workers=3,thread_name_prefix='proxy-health') + with db() as c:c.execute('create table if not exists proxy_health(id text primary key,checked_at integer,status text,latency_ms integer,http_status integer,message text)') + def rows(self): + with self.db() as c:rows=[dict(r) for r in c.execute('select p.id,p.label,p.enabled,h.checked_at,h.status,h.latency_ms,h.http_status,h.message from proxies p left join proxy_health h on p.id=h.id order by p.enabled desc,p.label,p.id')] + with self.lock:active=set(self.active) + for r in rows: + r['testing']=r['id'] in active;r['status']=r['status'] or 'untested' + return rows + def refs(self,pid): + return [p+'/'+a for p,pc in self.settings()['providers'].items() for a in ['balance','checkin','credential'] if pc[a].get('route')==pid] + def toggle(self,pid,enabled): + if not isinstance(enabled,bool):raise ValueError('enabled 必须为布尔值') + if not enabled and self.refs(pid):raise ValueError('节点被固定出口设置引用,请先更换对应出口') + with self.db() as c: + if not c.execute('update proxies set enabled=? where id=?',(int(enabled),pid)).rowcount:raise ValueError('节点不存在') + return {'ok':True,'enabled':enabled} + def delete(self,pid): + if self.refs(pid):raise ValueError('节点被固定出口设置引用,请先更换对应出口') + with self.lock: + if pid in self.active:raise ValueError('节点正在检测,请等待检测结束') + with self.db() as c: + c.execute('delete from proxies where id=?',(pid,));c.execute('delete from proxy_health where id=?',(pid,)) + return {'ok':True} + def start(self,ids): + if not isinstance(ids,list) or not 1<=len(ids)<=100 or any(not isinstance(i,str) for i in ids):raise ValueError('请选择1—100个节点') + with self.db() as c:known={r['id']:dict(r) for r in c.execute('select * from proxies')} + if any(i not in known for i in ids):raise ValueError('包含不存在的节点') + added=[] + with self.lock: + if len(self.active)+len(set(ids)-self.active)>105:raise ValueError('检测队列已满') + for i in dict.fromkeys(ids): + if i in self.active:continue + self.active.add(i);added.append(i) + for i in added:self.pool.submit(self._run,known[i]) + return {'queued':len(added),'skipped':len(set(ids))-len(added),'concurrency':3,'timeoutSeconds':12} + def _run(self,row): + start=time.monotonic();status='failed';code=0;ms=None;message='代理连通检测失败' + try: + u=urllib.parse.urlsplit(self.decode(row['secret'])['url']);ipaddress.IPv4Address(u.hostname) + typ={'http':'http','socks5':'socks5','socks4':'socks4'}[u.scheme];user=urllib.parse.unquote(u.username or '');pw=urllib.parse.unquote(u.password or '') + if not u.port or any(ch.isspace() for ch in user+pw):raise ValueError('invalid proxy') + env={k:v for k,v in os.environ.items() if 'proxy' not in k.lower()} + with tempfile.TemporaryDirectory(prefix='node-health-') as tmp: + cfg=pathlib.Path(tmp)/'proxychains.conf';cfg.write_text('strict_chain\nquiet_mode\nproxy_dns\ntcp_read_time_out 7000\ntcp_connect_time_out 5000\n[ProxyList]\n'+f'{typ} {u.hostname} {u.port}'+(f' {user} {pw}' if user else '')+'\n');cfg.chmod(0o600) + env.update(LD_PRELOAD='/usr/lib/x86_64-linux-gnu/libproxychains.so.4',PROXYCHAINS_CONF_FILE=str(cfg)) + script="import urllib.request,json,time; t=time.monotonic(); o=urllib.request.build_opener(urllib.request.ProxyHandler({})); r=o.open('https://www.gstatic.com/generate_204',timeout=9); r.read(32); print(json.dumps({'code':r.status,'ms':round((time.monotonic()-t)*1000)}))" + p=subprocess.run(['/usr/local/bin/python3','-c',script],env=env,capture_output=True,text=True,timeout=12) + if p.returncode==0: + v=json.loads(p.stdout);code=int(v['code']);ms=int(v['ms']);status='ok' if code==204 else 'failed';message='HTTPS 204 连通成功' if status=='ok' else '检测端点未返回预期状态' + else:message='代理连接、认证、DNS 或 TLS 失败(不代表账户失效)' + except subprocess.TimeoutExpired:status='timeout';message='检测超过12秒' + except Exception:message='节点配置或连通检测失败' + finally: + try: + with self.db() as c:c.execute('insert or replace into proxy_health values(?,?,?,?,?,?)',(row['id'],int(time.time()*1000),status,ms,code,message)) + if hasattr(self,'on_result'):self.on_result(row['id'],status) + finally: + with self.lock:self.active.discard(row['id']) diff --git a/contrib/maintenance-sidecar/ux.js b/contrib/maintenance-sidecar/ux.js new file mode 100644 index 000000000..526dc4f23 --- /dev/null +++ b/contrib/maintenance-sidecar/ux.js @@ -0,0 +1,44 @@ +'use strict'; +// Only visible views render. Account pages and metadata have independent requests. +let healthDirty=false,healthSignature='',sessionEpoch=0,metadataFlight=null,settingsSignature='',accountData=null,proxyPage=0,proxyVisible=[],proxySelected=new Set(),proxyFlight=null; +const requests=new Map(),pageCache=new Map(); +function enhanceSelects(root=document){root.querySelectorAll('select').forEach(el=>{if(el.tomselect)return;new TomSelect(el,{create:false,allowEmptyOption:true,dropdownParent:'body',maxOptions:200,searchField:['text'],render:{no_results:()=>'
没有匹配选项
'},onInitialize(){this.control_input.setAttribute('aria-label',el.getAttribute('aria-label')||el.closest('label')?.textContent.trim()||'选择选项');}})})} +function destroySelects(root){root.querySelectorAll('select').forEach(el=>el.tomselect?.destroy())} +async function api(path,body,signal){const controller=new AbortController(),timer=setTimeout(()=>controller.abort('timeout'),20000);const cancel=()=>controller.abort('superseded');signal?.addEventListener('abort',cancel,{once:true});try{const r=await fetch(path,{method:body===undefined?'GET':'POST',credentials:'same-origin',signal:controller.signal,headers:body===undefined?{}:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body)});let value;try{value=await r.json()}catch{throw Error('返回内容异常,请重试')}if(r.status===401){showLogin();const e=Error('登录已失效,请重新登录');e.status=401;throw e}if(!r.ok)throw Error(value.error||`请求失败 (${r.status})`);return value}catch(e){if(controller.signal.aborted){const x=Error(controller.signal.reason==='superseded'?'请求已更新':'请求超时,请重试');x.name='AbortError';throw x}throw e}finally{clearTimeout(timer);signal?.removeEventListener('abort',cancel)}} +function showLogin(){sessionEpoch++;$('boot').classList.add('hidden');$('app').classList.add('hidden');$('login').classList.remove('hidden')} +async function busy(button,work){if(button?.disabled||button?.getAttribute('aria-busy')==='true')return;if(button){button.disabled=true;button.setAttribute('aria-busy','true')}try{return await work()}catch(e){if(e.name!=='AbortError')toast(e.message)}finally{if(button){button.disabled=false;button.removeAttribute('aria-busy')}}} +function confirmAction(text,title='确认操作'){return new Promise(resolve=>{const d=$('confirm-dialog');$('confirm-text').textContent=text;$('confirm-title').textContent=title;d.returnValue='';d.addEventListener('close',()=>resolve(d.returnValue==='ok'),{once:true});d.showModal()})} +function fetchStatus(key,text,kind='',retry=false){const el=$(key+'-status');if(!el)return;el.className='fetch-status '+kind;el.textContent=text;if(retry){const b=document.createElement('button');b.textContent='重试';b.dataset.retry=key;el.appendChild(b)}} +function emptyRow(columns,title,detail=''){return `${esc(title)}${esc(detail)}`} +async function tableRequest(key,url,draw,background=false){const previous=requests.get(key);if(background&&previous?.url===url)return;previous?.controller.abort();const controller=new AbortController(),record={url,controller};requests.set(key,record);const cached=pageCache.get(url);if(cached)draw(cached);else if(!background)$(key+'-rows').innerHTML=emptyRow(key==='account'?8:key==='queue'?8:7,'正在加载…','正在读取当前筛选下的记录');fetchStatus(key,background?'正在同步…':'正在加载…','loading');const wrap=$(key+'-rows').closest('.table-wrap');wrap.setAttribute('aria-busy','true');try{const v=await api(url,undefined,controller.signal);if(requests.get(key)!==record)return;pageCache.set(url,v);if(pageCache.size>30)pageCache.delete(pageCache.keys().next().value);draw(v);fetchStatus(key,'已更新 '+new Date().toLocaleTimeString('zh-CN',{hour12:false})+' · '+fmt(v.total)+' 条')}catch(e){if(requests.get(key)!==record||e.name==='AbortError')return;fetchStatus(key,e.message+(cached?';保留上次数据':''),'error',true);if(!cached)$(key+'-rows').innerHTML=emptyRow(key==='history'?7:8,'加载失败','请点击上方重试,不会重新执行维护任务')}finally{if(requests.get(key)===record){requests.delete(key);wrap.removeAttribute('aria-busy')}}} +async function load(){if(metadataFlight)return metadataFlight;const epoch=sessionEpoch;metadataFlight=(async()=>{try{const result=await api('/api/state?compact=1');if(epoch!==sessionEpoch)return;state=result;$('boot').classList.add('hidden');$('login').classList.add('hidden');$('app').classList.remove('hidden');const warning=state.viewError||(state.viewAgeSeconds>30?'维护快照延迟 '+state.viewAgeSeconds+' 秒,请留意数据时间':'');$('connection-warning').classList.toggle('hidden',!warning);$('connection-warning').textContent=warning;render();if(tab==='accounts')await loadAccounts(true);if(tab==='queue')await loadQueue(true);if(tab==='history')await loadHistory(true);if(tab==='proxies')renderProxies()}catch(e){if(e.status===401)return;if(state){$('connection-warning').textContent='更新失败,保留旧数据:'+e.message;$('connection-warning').classList.remove('hidden');$('live-indicator').textContent='连接异常'}else{$('boot').querySelector('strong').textContent='暂时无法读取维护中心';$('boot').querySelector('p').textContent=e.message;$('boot-retry').classList.remove('hidden')}}finally{metadataFlight=null}})();return metadataFlight} +function render(){if(!state)return;$('live-indicator').textContent=state.config.paused?'调度已暂停':'调度运行中';$('live-indicator').className='pill '+(state.config.paused?'warn':'good');$('global-pause').textContent=state.config.paused?'恢复调度':'暂停调度';$('nav-queue').textContent=fmt(pending('','','queued'));$('last-updated').textContent='账户快照 '+clock(state.at)+' · 官方调度 '+clock(state.dashboard.routing.at);if(tab==='overview')renderOverview();if(tab==='history')renderHistorySummary();if(tab==='queue')$('queue-metrics').innerHTML=kpi('余额执行中',pending('','balance','running'),`共享 ${state.config.globalBalanceConcurrency||16} 并发`,'purple')+kpi('签到执行中',pending('','checkin','running'),'每平台低并发')+kpi('凭证维护中',pending('','credential','running'),'临期优先')+kpi('等待执行',pending('','','queued'),'同账户同操作去重');if(tab==='settings')renderSettings()} +function renderSettings(force=false){const sig=JSON.stringify([state.config,state.proxies.map(p=>[p.id,p.label,p.enabled])]);if(dirty||(!force&&sig===settingsSignature))return;destroySelects($('provider-settings'));paintSettings();enhanceSelects($('view-settings'));settingsSignature=sig} +function drawAccounts(v){accountData=v;ap=v.page;visible=v.items;$('account-rows').innerHTML=visible.map(r=>`${esc(r.name)}${platform(r.provider)} · ${r.enabled?'已启用':'已停用'}${pill(r.routing.state)}${r.needsLogin?'维护检测需重登':''}${r.unlimited?'不限量':r.balanceKind==='unknown'?'未知':fmt(r.balance)}${r.balanceDetail.fresh?'周期内查询值':'旧值 / 未知'}${clock(r.lastSuccess)}${pill(r.balanceDetail.state)}${r.balanceDetail.waitingForCheckin?'签到成功后自动补查;零点不扫描': ['needs_login','disabled','paused'].includes(r.balanceDetail.state)?'暂停安排,需先处理状态':clock(r.balanceDetail.nextEligibleAt)}${pill(r.checkinDetail.state)}今日 ${r.checkinDetail.attempts}/${r.checkinDetail.maxAttempts} 次${r.credentialRenewable?'支持续期':'不支持续期'}${r.cooldownUntil>v.at?'冷却至 '+clock(r.cooldownUntil):r.credentialExpiresAt?'到期 '+clock(r.credentialExpiresAt):'无确定到期时间'}`).join('')||emptyRow(8,'没有符合条件的账户','可更换平台、搜索词或状态筛选');pager('account',ap,v.total);updateSelection()} +function updateSelection(){$('selected-count').textContent=`已选 ${selected.size} 个`;$('select-page').checked=visible.length>0&&visible.every(r=>selected.has(r.id));$('select-page').indeterminate=visible.some(r=>selected.has(r.id))&&!$('select-page').checked;document.querySelectorAll('[data-batch]').forEach(b=>b.disabled=!selected.size||selected.size>100)} +function loadAccounts(background=false){return tableRequest('account','/api/accounts?'+new URLSearchParams({page:ap,size:50,provider:$('account-provider').value,filter:$('account-filter').value,search:$('account-search').value}),drawAccounts,background)} +function drawQueue(v){qdata=v;qp=v.page;$('queue-rows').innerHTML=v.items.map(j=>`${j.position?'#'+j.position:'—'}${esc(j.name)}${platform(j.provider)}${action(j.action)}${origin(j.origin)}${pill(j.status)}${duration(j.waitSeconds)}${clock(j.created)}${j.started?'开始 '+clock(j.started):'尚未执行'}${esc(j.holdReason||'等待并发名额')}`).join('')||emptyRow(8,'当前没有符合条件的排队任务','已完成任务可在执行记录查看');pager('queue',qp,v.total)} +function loadQueue(background=false){return tableRequest('queue','/api/queue?'+new URLSearchParams({page:qp,size:50,provider:$('queue-provider').value,action:$('queue-action').value,status:$('queue-status').value}),drawQueue,background)} +function drawHistory(v){hdata=v;hp=v.page;$('history-rows').innerHTML=v.items.map(j=>`${clock(j.created)}${esc(j.name||j.account)}${platform(j.provider)}${action(j.action)}${origin(j.origin)}${pill(j.status)}${j.ended&&j.started?duration((j.ended-j.started)/1000):'—'}${esc(j.proxy||'—')}${esc(j.message)}`).join('')||emptyRow(7,'暂无匹配的执行记录','请调整筛选条件');pager('history',hp,v.total)} +function loadHistory(background=false){return tableRequest('history','/api/history?'+new URLSearchParams({page:hp,size:50,provider:$('history-provider').value,action:$('history-action').value,status:$('history-status').value}),drawHistory,background)} +function goto(next){if(!titles[next])return;tab=next;document.querySelectorAll('[data-tab]').forEach(b=>{const active=b.dataset.tab===next;b.classList.toggle('active',active);b.setAttribute('aria-current',active?'page':'false')});for(const key of Object.keys(titles))$('view-'+key).classList.toggle('hidden',key!==next);$('crumb').textContent=titles[next];enhanceSelects($('view-'+next));render();if(next==='accounts')loadAccounts();if(next==='queue')loadQueue();if(next==='history')loadHistory();if(next==='proxies'){renderProxies();loadProxies()}} +function renderProxies(){paintHealthPolicy();const rows=state?.proxies||[],term=$('proxy-search').value.trim().toLowerCase(),filter=$('proxy-filter').value;const list=rows.filter(p=>(!term||p.label.toLowerCase().includes(term))&&(!filter||filter==='enabled'&&p.enabled||filter==='disabled'&&!p.enabled||filter==='ok'&&p.status==='ok'||filter==='failed'&&['failed','timeout'].includes(p.status)||filter==='untested'&&p.status==='untested'||filter==='testing'&&p.testing||filter==='quarantined'&&p.autoBlocked||filter==='usable'&&p.enabled&&!p.autoBlocked));proxyPage=Math.min(proxyPage,Math.max(0,Math.ceil(list.length/20)-1));proxyVisible=list.slice(proxyPage*20,(proxyPage+1)*20);$('proxy-kpis').innerHTML=kpi('维护节点',rows.length,'不显示节点凭证')+kpi('维护可用',rows.filter(p=>p.enabled&&!p.autoBlocked).length,'手动启用且未被自动隔离','purple')+kpi('自动隔离',rows.filter(p=>p.enabled&&p.autoBlocked).length,'复测恢复后自动重新加入','purple')+kpi('检测中 / 等待',rows.filter(p=>p.testing).length,'3并发 · 最长12秒 / 节点');$('proxy-count').textContent=`启用 ${rows.filter(p=>p.enabled).length} / ${rows.length}`;$('proxy-rows').innerHTML=proxyVisible.map(p=>`${esc(p.label)}${p.enabled?'启用':'手动停用'}${p.autoBlocked?'自动隔离 · 不参与维护':''}${p.testing?'检测中…':p.status==='ok'?fmt(p.latency_ms)+' ms':p.status==='timeout'?'超时':p.status==='failed'?'失败':'未检测'}${clock(p.checked_at)}${!p.enabled?'手动停用,不自动检测':!state.proxyHealth?.config.enabled?'自动巡检已关闭':p.testing?'正在检测':p.nextCheckAt?'下次 '+clock(p.nextCheckAt):'等待首次排期'}${esc(p.message||'尚未检测')}连续失败 ${p.failureStreak||0} · 连续成功 ${p.successStreak||0}
`).join('')||emptyRow(7,'没有符合条件的节点','可调整筛选或添加维护节点');$('proxy-page-info').textContent=`共 ${fmt(list.length)} 个 · 第 ${proxyPage+1} / ${Math.max(1,Math.ceil(list.length/20))} 页`;$('proxy-prev').disabled=proxyPage===0;$('proxy-next').disabled=(proxyPage+1)*20>=list.length;$('proxy-selected').textContent=`已选 ${proxySelected.size} 个`;$('proxy-test-selected').disabled=!proxySelected.size||proxySelected.size>100;$('proxy-test-page').disabled=!proxyVisible.length;$('proxy-select-page').checked=proxyVisible.length>0&&proxyVisible.every(p=>proxySelected.has(p.id))} +async function loadProxies(){if(proxyFlight)return proxyFlight;proxyFlight=(async()=>{fetchStatus('proxy','正在同步节点…','loading');try{const v=await api('/api/proxies');state.proxies=v.items;if(v.health)state.proxyHealth=v.health;const ids=new Set(v.items.map(p=>p.id));proxySelected=new Set([...proxySelected].filter(id=>ids.has(id)));if(tab==='proxies')renderProxies();fetchStatus('proxy','节点状态已更新 · '+new Date().toLocaleTimeString('zh-CN',{hour12:false}))}catch(e){fetchStatus('proxy',e.message,'error',true)}finally{proxyFlight=null}})();return proxyFlight} +async function testNodes(ids){if(!ids.length||ids.length>100)return toast('请选择1—100个节点');if(ids.length>1&&!await confirmAction(`检测所选 ${ids.length} 个节点的 HTTPS 连通延迟。3并发,不查询账户、不签到;检测结果会更新自动隔离状态,手动启停开关不变。`,'检测维护节点'))return;const v=await api('/api/proxies/test',{ids});toast(`已安排 ${v.queued} 个,已有检测跳过 ${v.skipped} 个`);await loadProxies()} +function markDirty(){dirty=true;$('dirty-label').classList.remove('hidden')} +async function saveSettings(){const fields=[...$('view-settings').querySelectorAll('input[type=number],input[type=time]')];if(fields.some(x=>!x.reportValidity()))return;const v={providers:{}};for(const k of ['globalConcurrency','globalBalanceConcurrency','queueBufferMultiplier','timeout'])v[k]=Number($(k).value);document.querySelectorAll('[data-provider]').forEach(el=>{const c={};el.querySelectorAll('[data-platform]').forEach(i=>c[i.dataset.platform]=Number(i.value));el.querySelectorAll('[data-setting-action]').forEach(block=>{const x={};block.querySelectorAll('[data-k]').forEach(i=>x[i.dataset.k]=i.type==='checkbox'?i.checked:i.dataset.minutes?Number(i.value)*60:['route','start','end'].includes(i.dataset.k)?i.value:Number(i.value));c[block.dataset.settingAction]=x});v.providers[el.dataset.provider]=c});state.config=await api('/api/settings',v);dirty=false;$('dirty-label').classList.add('hidden');settingsSignature='';renderSettings();toast('调度设置已保存并生效');await load()} +document.addEventListener('click',async e=>{const b=e.target.closest('button');if(!b||b.disabled)return;if(b.dataset.tab)return goto(b.dataset.tab);if(b.dataset.goto)return goto(b.dataset.goto);if(b.dataset.retry){const k=b.dataset.retry;return k==='account'?loadAccounts():k==='queue'?loadQueue():k==='history'?loadHistory():loadProxies()}if(b.dataset.pauseProvider)return busy(b,async()=>{if(dirty&&!await confirmAction('有未保存的设置,是否丢弃编辑并修改队列状态?'))return;const p=b.dataset.pauseProvider,a=b.dataset.pauseAction,next=!state.config.providers[p][a].paused;state.config=await api('/api/settings',{providers:{[p]:{[a]:{paused:next}}}});dirty=false;$('dirty-label').classList.add('hidden');toast(next?'队列已暂停,在途请求继续完成':'队列已恢复');await load()});if(b.dataset.cancelJob)return busy(b,async()=>{if(!await confirmAction('只取消这条待执行任务。自动任务以后可能再次入队;持续停用请暂停对应队列。','取消排队任务'))return;const v=await api('/api/jobs/cancel',{jobIds:[b.dataset.cancelJob]});toast(v.cancelled?'已取消':'任务已开始或完成,未取消');await loadQueue()});if(b.dataset.batch)return busy(b,async()=>{if(!selected.size||selected.size>100)return toast('请选择1—100个账户');if(!await confirmAction(`将所选 ${selected.size} 个账户加入${action(b.dataset.batch)}队列?已有任务会跳过。`))return;const v=await api('/api/jobs',{ids:[...selected],action:b.dataset.batch});toast(`已入队 ${v.added},跳过 ${v.skipped}`);await load()});if(b.dataset.testProxy)return busy(b,()=>testNodes([b.dataset.testProxy]));if(b.dataset.toggleProxy)return busy(b,async()=>{const p=state.proxies.find(p=>p.id===b.dataset.toggleProxy);if(!await confirmAction(`${p.enabled?'停用':'启用'}这个维护节点?在途请求不会中断,模型请求出口不受影响。`))return;await api('/api/proxies/toggle',{id:p.id,enabled:!p.enabled});await loadProxies()});if(b.dataset.deleteProxy)return busy(b,async()=>{if(!await confirmAction('删除这个维护节点?固定使用中的节点会拒绝删除,不会自动回退直连。','删除节点'))return;await api('/api/proxies/delete',{id:b.dataset.deleteProxy});await loadProxies()})}); +$('login-form').onsubmit=async e=>{e.preventDefault();await busy($('login-submit'),async()=>{try{await api('/api/login',{username:$('username').value,password:$('password').value});$('password').value='';$('login-error').textContent='';state=null;await load();if(state)goto('overview')}catch(e){$('login-error').textContent=e.message}})}; +$('logout').onclick=()=>busy($('logout'),async()=>{sessionEpoch++;for(const r of requests.values())r.controller.abort();await api('/api/logout',{});state=null;selected.clear();pageCache.clear();showLogin()});$('reload').onclick=()=>busy($('reload'),load);$('boot-retry').onclick=load; +$('global-pause').onclick=()=>busy($('global-pause'),async()=>{if(!await confirmAction(state.config.paused?'恢复所有未单独暂停的维护队列?':'暂停后不再派发新任务,已执行中的请求继续完成。'))return;state.config=await api('/api/settings',{paused:!state.config.paused});await load()}); +let searchTimer;function accountFilterChanged(){ap=0;selected.clear();updateSelection();loadAccounts()}$('account-search').oninput=()=>{clearTimeout(searchTimer);searchTimer=setTimeout(accountFilterChanged,250)};for(const id of ['account-provider','account-filter'])$(id).onchange=accountFilterChanged; +$('account-prev').onclick=()=>{ap=Math.max(0,ap-1);loadAccounts()};$('account-next').onclick=()=>{ap++;loadAccounts()};$('account-rows').onchange=e=>{if(e.target.dataset.accountId){e.target.checked?selected.add(e.target.dataset.accountId):selected.delete(e.target.dataset.accountId);updateSelection()}};$('select-page').onchange=e=>{visible.forEach(r=>e.target.checked?selected.add(r.id):selected.delete(r.id));drawAccounts(accountData)}; +for(const group of ['queue','history']){const go=()=>group==='queue'?loadQueue():loadHistory();for(const field of ['provider','action','status'])$(group+'-'+field).onchange=()=>{group==='queue'?qp=0:hp=0;go()};$(group+'-prev').onclick=()=>{group==='queue'?qp=Math.max(0,qp-1):hp=Math.max(0,hp-1);go()};$(group+'-next').onclick=()=>{group==='queue'?qp++:hp++;go()};$(group+'-reload').onclick=()=>busy($(group+'-reload'),go)} +$('view-settings').oninput=markDirty;$('view-settings').onchange=markDirty;$('save-settings').onclick=()=>busy($('save-settings'),saveSettings); +$('proxy-search').oninput=()=>{proxyPage=0;renderProxies()};$('proxy-filter').onchange=()=>{proxyPage=0;renderProxies()};$('proxy-prev').onclick=()=>{proxyPage--;renderProxies()};$('proxy-next').onclick=()=>{proxyPage++;renderProxies()};$('proxy-reload').onclick=()=>busy($('proxy-reload'),loadProxies);$('proxy-test-selected').onclick=()=>busy($('proxy-test-selected'),()=>testNodes([...proxySelected]));$('proxy-test-page').onclick=()=>busy($('proxy-test-page'),()=>testNodes(proxyVisible.map(p=>p.id)));$('proxy-rows').onchange=e=>{if(e.target.dataset.proxySelect){e.target.checked?proxySelected.add(e.target.dataset.proxySelect):proxySelected.delete(e.target.dataset.proxySelect);renderProxies()}};$('proxy-select-page').onchange=e=>{proxyVisible.forEach(p=>e.target.checked?proxySelected.add(p.id):proxySelected.delete(p.id));renderProxies()}; +$('proxy-form').onsubmit=async e=>{e.preventDefault();await busy(e.currentTarget.querySelector('button'),async()=>{await api('/api/proxies',{label:$('proxy-label').value,url:$('proxy-url').value});$('proxy-url').value='';$('proxy-label').value='';toast('节点已添加;尚未主动检测');await loadProxies()})}; +function paintHealthPolicy(){const h=state?.proxyHealth;if(!h)return;const cfg=h.config;const sig=JSON.stringify(cfg);$('health-state').textContent=h.error?'巡检异常':cfg.enabled?'自动巡检中':'自动巡检关闭';$('health-state').className='pill '+(h.error?'warn':cfg.enabled?'good':'');if(!healthDirty&&sig!==healthSignature){$('health-enabled').checked=cfg.enabled;for(const k of ['interval','retryInterval','confirmInterval','failureThreshold','recoveryThreshold','concurrency'])$('health-'+k).value=['interval','retryInterval'].includes(k)?cfg[k]/60:cfg[k];healthSignature=sig}$('health-explainer').textContent=h.error||`连续失败 ${cfg.failureThreshold} 次隔离;连续成功 ${cfg.recoveryThreshold} 次恢复。首次排期错开最多2分钟,后续含少量随机延迟。手动停用不会自动启用;关闭巡检也不会清除已有隔离。最近检查:${clock(h.lastTickAt)}。`} +$('health-form').oninput=()=>{healthDirty=true;$('health-save-state').textContent='有未保存的巡检设置'}; +$('health-form').onsubmit=async e=>{e.preventDefault();await busy($('save-health'),async()=>{if(!$('health-form').reportValidity())return;const config={enabled:$('health-enabled').checked};for(const k of ['interval','retryInterval','confirmInterval','failureThreshold','recoveryThreshold','concurrency'])config[k]=Number($('health-'+k).value)*(['interval','retryInterval'].includes(k)?60:1);const saved=await api('/api/proxies/health-settings',config);if(state.proxyHealth)state.proxyHealth.config=saved;healthDirty=false;healthSignature='';$('health-save-state').textContent='已保存并生效';toast('自动巡检设置已保存');await loadProxies()})}; +window.addEventListener('beforeunload',e=>{if(dirty||healthDirty){e.preventDefault();e.returnValue=''}}); +load();setInterval(()=>{if(!document.hidden&&!$('app').classList.contains('hidden'))load()},10000);setInterval(()=>{if(tab==='proxies'&&state&&!document.hidden&&state.proxies.some(p=>p.testing))loadProxies()},2500); diff --git a/contrib/maintenance-sidecar/vendor-manifest.json b/contrib/maintenance-sidecar/vendor-manifest.json new file mode 100644 index 000000000..b3dea02f9 --- /dev/null +++ b/contrib/maintenance-sidecar/vendor-manifest.json @@ -0,0 +1,11 @@ +{ + "name": "tom-select", + "version": "2.4.3", + "source": "https://registry.npmjs.org/tom-select/-/tom-select-2.4.3.tgz", + "archive_sha256": "6a40f60febcdd909aa8d269ac9efaf7e2e88480c67e24ffe185ddeb7abe148b9", + "vendored_files": { + "LICENSE.tom-select": "f15dc64ea1f373c8af0ee7c020064360497addf335c9c2bcf27c3a6f56a6f0bd", + "tom-select.css": "7b29f39e2916acfb3d050e00df8fcdfca89279ead2d8e654afec307ca63b10f9", + "tom-select.min.js": "b797005cf23378fb38448b80dc57a33313a5c577b841765743bb1f289c4d53e6" + } +} \ No newline at end of file diff --git a/contrib/maintenance-sidecar/vendor/LICENSE.tom-select b/contrib/maintenance-sidecar/vendor/LICENSE.tom-select new file mode 100644 index 000000000..ea75a94ec --- /dev/null +++ b/contrib/maintenance-sidecar/vendor/LICENSE.tom-select @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2013–2015 Brian Reavis + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/contrib/maintenance-sidecar/vendor/tom-select.css b/contrib/maintenance-sidecar/vendor/tom-select.css new file mode 100644 index 000000000..6f5c8d8aa --- /dev/null +++ b/contrib/maintenance-sidecar/vendor/tom-select.css @@ -0,0 +1,412 @@ +/** + * tom-select.css (v2.4.3) + * Copyright (c) contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this + * file except in compliance with the License. You may obtain a copy of the License at: + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF + * ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + * + */ +.ts-control { + border: 1px solid #d0d0d0; + padding: 8px 8px; + width: 100%; + overflow: hidden; + position: relative; + z-index: 1; + box-sizing: border-box; + box-shadow: none; + border-radius: 3px; + display: flex; + flex-wrap: wrap; +} +.ts-wrapper.multi.has-items .ts-control { + padding: calc(8px - 2px - 0) 8px calc(8px - 2px - 3px - 0); +} +.full .ts-control { + background-color: #fff; +} +.disabled .ts-control, .disabled .ts-control * { + cursor: default !important; +} +.focus .ts-control { + box-shadow: none; +} +.ts-control > * { + vertical-align: baseline; + display: inline-block; +} +.ts-wrapper.multi .ts-control > div { + cursor: pointer; + margin: 0 3px 3px 0; + padding: 2px 6px; + background: #f2f2f2; + color: #303030; + border: 0 solid #d0d0d0; +} +.ts-wrapper.multi .ts-control > div.active { + background: #e8e8e8; + color: #303030; + border: 0 solid #cacaca; +} +.ts-wrapper.multi.disabled .ts-control > div, .ts-wrapper.multi.disabled .ts-control > div.active { + color: rgb(124.5, 124.5, 124.5); + background: white; + border: 0 solid white; +} +.ts-control > input { + flex: 1 1 auto; + min-width: 7rem; + display: inline-block !important; + padding: 0 !important; + min-height: 0 !important; + max-height: none !important; + max-width: 100% !important; + margin: 0 !important; + text-indent: 0 !important; + border: 0 none !important; + background: none !important; + line-height: inherit !important; + -webkit-user-select: auto !important; + -moz-user-select: auto !important; + -ms-user-select: auto !important; + user-select: auto !important; + box-shadow: none !important; +} +.ts-control > input::-ms-clear { + display: none; +} +.ts-control > input:focus { + outline: none !important; +} +.has-items .ts-control > input { + margin: 0 4px !important; +} +.ts-control.rtl { + text-align: right; +} +.ts-control.rtl.single .ts-control:after { + left: 15px; + right: auto; +} +.ts-control.rtl .ts-control > input { + margin: 0 4px 0 -2px !important; +} +.disabled .ts-control { + opacity: 0.5; + background-color: #fafafa; +} +.input-hidden .ts-control > input { + opacity: 0; + position: absolute; + left: -10000px; +} + +.ts-dropdown { + position: absolute; + top: 100%; + left: 0; + width: 100%; + z-index: 10; + border: 1px solid #d0d0d0; + background: #fff; + margin: 0.25rem 0 0; + border-top: 0 none; + box-sizing: border-box; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border-radius: 0 0 3px 3px; +} +.ts-dropdown [data-selectable] { + cursor: pointer; + overflow: hidden; +} +.ts-dropdown [data-selectable] .highlight { + background: rgba(125, 168, 208, 0.2); + border-radius: 1px; +} +.ts-dropdown .option, +.ts-dropdown .optgroup-header, +.ts-dropdown .no-results, +.ts-dropdown .create { + padding: 5px 8px; +} +.ts-dropdown .option, .ts-dropdown [data-disabled], .ts-dropdown [data-disabled] [data-selectable].option { + cursor: inherit; + opacity: 0.5; +} +.ts-dropdown [data-selectable].option { + opacity: 1; + cursor: pointer; +} +.ts-dropdown .optgroup:first-child .optgroup-header { + border-top: 0 none; +} +.ts-dropdown .optgroup-header { + color: #303030; + background: #fff; + cursor: default; +} +.ts-dropdown .active { + background-color: #f5fafd; + color: #495c68; +} +.ts-dropdown .active.create { + color: #495c68; +} +.ts-dropdown .create { + color: rgba(48, 48, 48, 0.5); +} +.ts-dropdown .spinner { + display: inline-block; + width: 30px; + height: 30px; + margin: 5px 8px; +} +.ts-dropdown .spinner::after { + content: " "; + display: block; + width: 24px; + height: 24px; + margin: 3px; + border-radius: 50%; + border: 5px solid #d0d0d0; + border-color: #d0d0d0 transparent #d0d0d0 transparent; + animation: lds-dual-ring 1.2s linear infinite; +} +@keyframes lds-dual-ring { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.ts-dropdown-content { + overflow: hidden auto; + max-height: 200px; + scroll-behavior: smooth; +} + +.ts-wrapper.plugin-drag_drop .ts-dragging { + color: transparent !important; +} +.ts-wrapper.plugin-drag_drop .ts-dragging > * { + visibility: hidden !important; +} + +.plugin-checkbox_options:not(.rtl) .option input { + margin-right: 0.5rem; +} + +.plugin-checkbox_options.rtl .option input { + margin-left: 0.5rem; +} + +/* stylelint-disable function-name-case */ +.plugin-clear_button { + --ts-pr-clear-button: 1em; +} +.plugin-clear_button .clear-button { + opacity: 0; + position: absolute; + top: 50%; + transform: translateY(-50%); + right: calc(8px - 6px); + margin-right: 0 !important; + background: transparent !important; + transition: opacity 0.5s; + cursor: pointer; +} +.plugin-clear_button.form-select .clear-button, .plugin-clear_button.single .clear-button { + right: max(var(--ts-pr-caret), 8px); +} +.plugin-clear_button.focus.has-items .clear-button, .plugin-clear_button:not(.disabled):hover.has-items .clear-button { + opacity: 1; +} + +.ts-wrapper .dropdown-header { + position: relative; + padding: 10px 8px; + border-bottom: 1px solid #d0d0d0; + background: color-mix(#fff, #d0d0d0, 85%); + border-radius: 3px 3px 0 0; +} +.ts-wrapper .dropdown-header-close { + position: absolute; + right: 8px; + top: 50%; + color: #303030; + opacity: 0.4; + margin-top: -12px; + line-height: 20px; + font-size: 20px !important; +} +.ts-wrapper .dropdown-header-close:hover { + color: black; +} + +.plugin-dropdown_input.focus.dropdown-active .ts-control { + box-shadow: none; + border: 1px solid #d0d0d0; +} +.plugin-dropdown_input .dropdown-input { + border: 1px solid #d0d0d0; + border-width: 0 0 1px; + display: block; + padding: 8px 8px; + box-shadow: none; + width: 100%; + background: transparent; +} +.plugin-dropdown_input .items-placeholder { + border: 0 none !important; + box-shadow: none !important; + width: 100%; +} +.plugin-dropdown_input.has-items .items-placeholder, .plugin-dropdown_input.dropdown-active .items-placeholder { + display: none !important; +} + +.ts-wrapper.plugin-input_autogrow.has-items .ts-control > input { + min-width: 0; +} +.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input { + flex: none; + min-width: 4px; +} +.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::-ms-input-placeholder { + color: transparent; +} +.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::placeholder { + color: transparent; +} + +.ts-dropdown.plugin-optgroup_columns .ts-dropdown-content { + display: flex; +} +.ts-dropdown.plugin-optgroup_columns .optgroup { + border-right: 1px solid #f2f2f2; + border-top: 0 none; + flex-grow: 1; + flex-basis: 0; + min-width: 0; +} +.ts-dropdown.plugin-optgroup_columns .optgroup:last-child { + border-right: 0 none; +} +.ts-dropdown.plugin-optgroup_columns .optgroup::before { + display: none; +} +.ts-dropdown.plugin-optgroup_columns .optgroup-header { + border-top: 0 none; +} + +.ts-wrapper.plugin-remove_button .item { + display: inline-flex; + align-items: center; +} +.ts-wrapper.plugin-remove_button .item .remove { + color: inherit; + text-decoration: none; + vertical-align: middle; + display: inline-block; + padding: 0 6px; + border-radius: 0 2px 2px 0; + box-sizing: border-box; +} +.ts-wrapper.plugin-remove_button .item .remove:hover { + background: rgba(0, 0, 0, 0.05); +} +.ts-wrapper.plugin-remove_button.disabled .item .remove:hover { + background: none; +} +.ts-wrapper.plugin-remove_button .remove-single { + position: absolute; + right: 0; + top: 0; + font-size: 23px; +} + +.ts-wrapper.plugin-remove_button:not(.rtl) .item { + padding-right: 0 !important; +} +.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove { + border-left: 1px solid #d0d0d0; + margin-left: 6px; +} +.ts-wrapper.plugin-remove_button:not(.rtl) .item.active .remove { + border-left-color: #cacaca; +} +.ts-wrapper.plugin-remove_button:not(.rtl).disabled .item .remove { + border-left-color: white; +} + +.ts-wrapper.plugin-remove_button.rtl .item { + padding-left: 0 !important; +} +.ts-wrapper.plugin-remove_button.rtl .item .remove { + border-right: 1px solid #d0d0d0; + margin-right: 6px; +} +.ts-wrapper.plugin-remove_button.rtl .item.active .remove { + border-right-color: #cacaca; +} +.ts-wrapper.plugin-remove_button.rtl.disabled .item .remove { + border-right-color: white; +} + +:root { + --ts-pr-clear-button: 0px; + --ts-pr-caret: 0px; + --ts-pr-min: .75rem; +} + +.ts-wrapper.single .ts-control, .ts-wrapper.single .ts-control input { + cursor: pointer; +} + +.ts-control:not(.rtl) { + padding-right: max(var(--ts-pr-min), var(--ts-pr-clear-button) + var(--ts-pr-caret)) !important; +} + +.ts-control.rtl { + padding-left: max(var(--ts-pr-min), var(--ts-pr-clear-button) + var(--ts-pr-caret)) !important; +} + +.ts-wrapper { + position: relative; +} + +.ts-dropdown, +.ts-control, +.ts-control input { + color: #303030; + font-family: inherit; + font-size: 13px; + line-height: 18px; +} + +.ts-control, +.ts-wrapper.single.input-active .ts-control { + background: #fff; + cursor: text; +} + +.ts-hidden-accessible { + border: 0 !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; +} +/*# sourceMappingURL=tom-select.css.map */ \ No newline at end of file diff --git a/contrib/maintenance-sidecar/vendor/tom-select.min.js b/contrib/maintenance-sidecar/vendor/tom-select.min.js new file mode 100644 index 000000000..d0b9d300c --- /dev/null +++ b/contrib/maintenance-sidecar/vendor/tom-select.min.js @@ -0,0 +1,444 @@ +/** +* Tom Select v2.4.3 +* Licensed under the Apache License, Version 2.0 (the "License"); +*/ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).TomSelect=t()}(this,(function(){"use strict" +function e(e,t){e.split(/\s+/).forEach((e=>{t(e)}))}class t{constructor(){this._events={}}on(t,s){e(t,(e=>{const t=this._events[e]||[] +t.push(s),this._events[e]=t}))}off(t,s){var i=arguments.length +0!==i?e(t,(e=>{if(1===i)return void delete this._events[e] +const t=this._events[e] +void 0!==t&&(t.splice(t.indexOf(s),1),this._events[e]=t)})):this._events={}}trigger(t,...s){var i=this +e(t,(e=>{const t=i._events[e] +void 0!==t&&t.forEach((e=>{e.apply(i,s)}))}))}}const s=e=>(e=e.filter(Boolean)).length<2?e[0]||"":1==l(e)?"["+e.join("")+"]":"(?:"+e.join("|")+")",i=e=>{if(!o(e))return e.join("") +let t="",s=0 +const i=()=>{s>1&&(t+="{"+s+"}")} +return e.forEach(((n,o)=>{n!==e[o-1]?(i(),t+=n,s=1):s++})),i(),t},n=e=>{let t=Array.from(e) +return s(t)},o=e=>new Set(e).size!==e.length,r=e=>(e+"").replace(/([\$\(\)\*\+\.\?\[\]\^\{\|\}\\])/gu,"\\$1"),l=e=>e.reduce(((e,t)=>Math.max(e,a(t))),0),a=e=>Array.from(e).length,c=e=>{if(1===e.length)return[[e]] +let t=[] +const s=e.substring(1) +return c(s).forEach((function(s){let i=s.slice(0) +i[0]=e.charAt(0)+i[0],t.push(i),i=s.slice(0),i.unshift(e.charAt(0)),t.push(i)})),t},d=[[0,65535]] +let u,p +const h={},g={"/":"⁄∕",0:"߀",a:"ⱥɐɑ",aa:"ꜳ",ae:"æǽǣ",ao:"ꜵ",au:"ꜷ",av:"ꜹꜻ",ay:"ꜽ",b:"ƀɓƃ",c:"ꜿƈȼↄ",d:"đɗɖᴅƌꮷԁɦ",e:"ɛǝᴇɇ",f:"ꝼƒ",g:"ǥɠꞡᵹꝿɢ",h:"ħⱨⱶɥ",i:"ɨı",j:"ɉȷ",k:"ƙⱪꝁꝃꝅꞣ",l:"łƚɫⱡꝉꝇꞁɭ",m:"ɱɯϻ",n:"ꞥƞɲꞑᴎлԉ",o:"øǿɔɵꝋꝍᴑ",oe:"œ",oi:"ƣ",oo:"ꝏ",ou:"ȣ",p:"ƥᵽꝑꝓꝕρ",q:"ꝗꝙɋ",r:"ɍɽꝛꞧꞃ",s:"ßȿꞩꞅʂ",t:"ŧƭʈⱦꞇ",th:"þ",tz:"ꜩ",u:"ʉ",v:"ʋꝟʌ",vy:"ꝡ",w:"ⱳ",y:"ƴɏỿ",z:"ƶȥɀⱬꝣ",hv:"ƕ"} +for(let e in g){let t=g[e]||"" +for(let s=0;se.normalize(t),v=e=>Array.from(e).reduce(((e,t)=>e+y(t)),""),y=e=>(e=m(e).toLowerCase().replace(f,(e=>h[e]||"")),m(e,"NFC")) +const O=e=>{const t={},s=(e,s)=>{const i=t[e]||new Set,o=new RegExp("^"+n(i)+"$","iu") +s.match(o)||(i.add(r(s)),t[e]=i)} +for(let t of function*(e){for(const[t,s]of e)for(let e=t;e<=s;e++){let t=String.fromCharCode(e),s=v(t) +s!=t.toLowerCase()&&(s.length>3||0!=s.length&&(yield{folded:s,composed:t,code_point:e}))}}(e))s(t.folded,t.folded),s(t.folded,t.composed) +return t},b=e=>{const t=O(e),i={} +let o=[] +for(let e in t){let s=t[e] +s&&(i[e]=n(s)),e.length>1&&o.push(r(e))}o.sort(((e,t)=>t.length-e.length)) +const l=s(o) +return p=new RegExp("^"+l,"u"),i},w=(e,t=1)=>(t=Math.max(t,e.length-1),s(c(e).map((e=>((e,t=1)=>{let s=0 +return e=e.map((e=>(u[e]&&(s+=e.length),u[e]||e))),s>=t?i(e):""})(e,t))))),_=(e,t=!0)=>{let n=e.length>1?1:0 +return s(e.map((e=>{let s=[] +const o=t?e.length():e.length()-1 +for(let t=0;t{for(const s of t){if(s.start!=e.start||s.end!=e.end)continue +if(s.substrs.join("")!==e.substrs.join(""))continue +let t=e.parts +const i=e=>{for(const s of t){if(s.start===e.start&&s.substr===e.substr)return!1 +if(1!=e.length&&1!=s.length){if(e.starts.start)return!0 +if(s.starte.start)return!0}}return!1} +if(!(s.parts.filter(i).length>0))return!0}return!1} +class S{parts +substrs +start +end +constructor(){this.parts=[],this.substrs=[],this.start=0,this.end=0}add(e){e&&(this.parts.push(e),this.substrs.push(e.substr),this.start=Math.min(e.start,this.start),this.end=Math.max(e.end,this.end))}last(){return this.parts[this.parts.length-1]}length(){return this.parts.length}clone(e,t){let s=new S,i=JSON.parse(JSON.stringify(this.parts)),n=i.pop() +for(const e of i)s.add(e) +let o=t.substr.substring(0,e-n.start),r=o.length +return s.add({start:n.start,end:n.start+r,length:r,substr:o}),s}}const I=e=>{void 0===u&&(u=b(d)),e=v(e) +let t="",s=[new S] +for(let i=0;i0){l=l.sort(((e,t)=>e.length()-t.length())) +for(let e of l)C(e,s)||s.push(e)}else if(i>0&&1==a.size&&!a.has("3")){t+=_(s,!1) +let e=new S +const i=s[0] +i&&e.add(i.last()),s=[e]}}return t+=_(s,!0),t},A=(e,t)=>{if(e)return e[t]},k=(e,t)=>{if(e){for(var s,i=t.split(".");(s=i.shift())&&(e=e[s]););return e}},x=(e,t,s)=>{var i,n +return e?(e+="",null==t.regex||-1===(n=e.search(t.regex))?0:(i=t.string.length/e.length,0===n&&(i+=.5),i*s)):0},F=(e,t)=>{var s=e[t] +if("function"==typeof s)return s +s&&!Array.isArray(s)&&(e[t]=[s])},L=(e,t)=>{if(Array.isArray(e))e.forEach(t) +else for(var s in e)e.hasOwnProperty(s)&&t(e[s],s)},E=(e,t)=>"number"==typeof e&&"number"==typeof t?e>t?1:e(t=v(t+"").toLowerCase())?1:t>e?-1:0 +class T{items +settings +constructor(e,t){this.items=e,this.settings=t||{diacritics:!0}}tokenize(e,t,s){if(!e||!e.length)return[] +const i=[],n=e.split(/\s+/) +var o +return s&&(o=new RegExp("^("+Object.keys(s).map(r).join("|")+"):(.*)$")),n.forEach((e=>{let s,n=null,l=null +o&&(s=e.match(o))&&(n=s[1],e=s[2]),e.length>0&&(l=this.settings.diacritics?I(e)||null:r(e),l&&t&&(l="\\b"+l)),i.push({string:e,regex:l?new RegExp(l,"iu"):null,field:n})})),i}getScoreFunction(e,t){var s=this.prepareSearch(e,t) +return this._getScoreFunction(s)}_getScoreFunction(e){const t=e.tokens,s=t.length +if(!s)return function(){return 0} +const i=e.options.fields,n=e.weights,o=i.length,r=e.getAttrFn +if(!o)return function(){return 1} +const l=1===o?function(e,t){const s=i[0].field +return x(r(t,s),e,n[s]||1)}:function(e,t){var s=0 +if(e.field){const i=r(t,e.field) +!e.regex&&i?s+=1/o:s+=x(i,e,1)}else L(n,((i,n)=>{s+=x(r(t,n),e,i)})) +return s/o} +return 1===s?function(e){return l(t[0],e)}:"and"===e.options.conjunction?function(e){var i,n=0 +for(let s of t){if((i=l(s,e))<=0)return 0 +n+=i}return n/s}:function(e){var i=0 +return L(t,(t=>{i+=l(t,e)})),i/s}}getSortFunction(e,t){var s=this.prepareSearch(e,t) +return this._getSortFunction(s)}_getSortFunction(e){var t,s=[] +const i=this,n=e.options,o=!e.query&&n.sort_empty?n.sort_empty:n.sort +if("function"==typeof o)return o.bind(this) +const r=function(t,s){return"$score"===t?s.score:e.getAttrFn(i.items[s.id],t)} +if(o)for(let t of o)(e.query||"$score"!==t.field)&&s.push(t) +if(e.query){t=!0 +for(let e of s)if("$score"===e.field){t=!1 +break}t&&s.unshift({field:"$score",direction:"desc"})}else s=s.filter((e=>"$score"!==e.field)) +return s.length?function(e,t){var i,n +for(let o of s){if(n=o.field,i=("desc"===o.direction?-1:1)*E(r(n,e),r(n,t)))return i}return 0}:null}prepareSearch(e,t){const s={} +var i=Object.assign({},t) +if(F(i,"sort"),F(i,"sort_empty"),i.fields){F(i,"fields") +const e=[] +i.fields.forEach((t=>{"string"==typeof t&&(t={field:t,weight:1}),e.push(t),s[t.field]="weight"in t?t.weight:1})),i.fields=e}return{options:i,query:e.toLowerCase().trim(),tokens:this.tokenize(e,i.respect_word_boundaries,s),total:0,items:[],weights:s,getAttrFn:i.nesting?k:A}}search(e,t){var s,i,n=this +i=this.prepareSearch(e,t),t=i.options,e=i.query +const o=t.score||n._getScoreFunction(i) +e.length?L(n.items,((e,n)=>{s=o(e),(!1===t.filter||s>0)&&i.items.push({score:s,id:n})})):L(n.items,((e,t)=>{i.items.push({score:1,id:t})})) +const r=n._getSortFunction(i) +return r&&i.items.sort(r),i.total=i.items.length,"number"==typeof t.limit&&(i.items=i.items.slice(0,t.limit)),i}}const P=e=>null==e?null:N(e),N=e=>"boolean"==typeof e?e?"1":"0":e+"",j=e=>(e+"").replace(/&/g,"&").replace(//g,">").replace(/"/g,"""),$=(e,t)=>{var s +return function(i,n){var o=this +s&&(o.loading=Math.max(o.loading-1,0),clearTimeout(s)),s=setTimeout((function(){s=null,o.loadedSearches[i]=!0,e.call(o,i,n)}),t)}},V=(e,t,s)=>{var i,n=e.trigger,o={} +for(i of(e.trigger=function(){var s=arguments[0] +if(-1===t.indexOf(s))return n.apply(e,arguments) +o[s]=arguments},s.apply(e,[]),e.trigger=n,t))i in o&&n.apply(e,o[i])},q=(e,t=!1)=>{e&&(e.preventDefault(),t&&e.stopPropagation())},D=(e,t,s,i)=>{e.addEventListener(t,s,i)},H=(e,t)=>!!t&&(!!t[e]&&1===(t.altKey?1:0)+(t.ctrlKey?1:0)+(t.shiftKey?1:0)+(t.metaKey?1:0)),R=(e,t)=>{const s=e.getAttribute("id") +return s||(e.setAttribute("id",t),t)},M=e=>e.replace(/[\\"']/g,"\\$&"),z=(e,t)=>{t&&e.append(t)},B=(e,t)=>{if(Array.isArray(e))e.forEach(t) +else for(var s in e)e.hasOwnProperty(s)&&t(e[s],s)},K=e=>{if(e.jquery)return e[0] +if(e instanceof HTMLElement)return e +if(Q(e)){var t=document.createElement("template") +return t.innerHTML=e.trim(),t.content.firstChild}return document.querySelector(e)},Q=e=>"string"==typeof e&&e.indexOf("<")>-1,G=(e,t)=>{var s=document.createEvent("HTMLEvents") +s.initEvent(t,!0,!1),e.dispatchEvent(s)},U=(e,t)=>{Object.assign(e.style,t)},J=(e,...t)=>{var s=X(t);(e=Y(e)).map((e=>{s.map((t=>{e.classList.add(t)}))}))},W=(e,...t)=>{var s=X(t);(e=Y(e)).map((e=>{s.map((t=>{e.classList.remove(t)}))}))},X=e=>{var t=[] +return B(e,(e=>{"string"==typeof e&&(e=e.trim().split(/[\t\n\f\r\s]/)),Array.isArray(e)&&(t=t.concat(e))})),t.filter(Boolean)},Y=e=>(Array.isArray(e)||(e=[e]),e),Z=(e,t,s)=>{if(!s||s.contains(e))for(;e&&e.matches;){if(e.matches(t))return e +e=e.parentNode}},ee=(e,t=0)=>t>0?e[e.length-1]:e[0],te=(e,t)=>{if(!e)return-1 +t=t||e.nodeName +for(var s=0;e=e.previousElementSibling;)e.matches(t)&&s++ +return s},se=(e,t)=>{B(t,((t,s)=>{null==t?e.removeAttribute(s):e.setAttribute(s,""+t)}))},ie=(e,t)=>{e.parentNode&&e.parentNode.replaceChild(t,e)},ne=(e,t)=>{if(null===t)return +if("string"==typeof t){if(!t.length)return +t=new RegExp(t,"i")}const s=e=>3===e.nodeType?(e=>{var s=e.data.match(t) +if(s&&e.data.length>0){var i=document.createElement("span") +i.className="highlight" +var n=e.splitText(s.index) +n.splitText(s[0].length) +var o=n.cloneNode(!0) +return i.appendChild(o),ie(n,i),1}return 0})(e):((e=>{1!==e.nodeType||!e.childNodes||/(script|style)/i.test(e.tagName)||"highlight"===e.className&&"SPAN"===e.tagName||Array.from(e.childNodes).forEach((e=>{s(e)}))})(e),0) +s(e)},oe="undefined"!=typeof navigator&&/Mac/.test(navigator.userAgent)?"metaKey":"ctrlKey" +var re={options:[],optgroups:[],plugins:[],delimiter:",",splitOn:null,persist:!0,diacritics:!0,create:null,createOnBlur:!1,createFilter:null,highlight:!0,openOnFocus:!0,shouldOpen:null,maxOptions:50,maxItems:null,hideSelected:null,duplicates:!1,addPrecedence:!1,selectOnTab:!1,preload:null,allowEmptyOption:!1,refreshThrottle:300,loadThrottle:300,loadingClass:"loading",dataAttr:null,optgroupField:"optgroup",valueField:"value",labelField:"text",disabledField:"disabled",optgroupLabelField:"label",optgroupValueField:"value",lockOptgroupOrder:!1,sortField:"$order",searchField:["text"],searchConjunction:"and",mode:null,wrapperClass:"ts-wrapper",controlClass:"ts-control",dropdownClass:"ts-dropdown",dropdownContentClass:"ts-dropdown-content",itemClass:"item",optionClass:"option",dropdownParent:null,controlInput:'',copyClassesToDropdown:!1,placeholder:null,hidePlaceholder:null,shouldLoad:function(e){return e.length>0},render:{}} +function le(e,t){var s=Object.assign({},re,t),i=s.dataAttr,n=s.labelField,o=s.valueField,r=s.disabledField,l=s.optgroupField,a=s.optgroupLabelField,c=s.optgroupValueField,d=e.tagName.toLowerCase(),u=e.getAttribute("placeholder")||e.getAttribute("data-placeholder") +if(!u&&!s.allowEmptyOption){let t=e.querySelector('option[value=""]') +t&&(u=t.textContent)}var p={placeholder:u,options:[],optgroups:[],items:[],maxItems:null} +return"select"===d?(()=>{var t,d=p.options,u={},h=1 +let g=0 +var f=e=>{var t=Object.assign({},e.dataset),s=i&&t[i] +return"string"==typeof s&&s.length&&(t=Object.assign(t,JSON.parse(s))),t},m=(e,t)=>{var i=P(e.value) +if(null!=i&&(i||s.allowEmptyOption)){if(u.hasOwnProperty(i)){if(t){var a=u[i][l] +a?Array.isArray(a)?a.push(t):u[i][l]=[a,t]:u[i][l]=t}}else{var c=f(e) +c[n]=c[n]||e.textContent,c[o]=c[o]||i,c[r]=c[r]||e.disabled,c[l]=c[l]||t,c.$option=e,c.$order=c.$order||++g,u[i]=c,d.push(c)}e.selected&&p.items.push(i)}} +p.maxItems=e.hasAttribute("multiple")?null:1,B(e.children,(e=>{var s,i,n +"optgroup"===(t=e.tagName.toLowerCase())?((n=f(s=e))[a]=n[a]||s.getAttribute("label")||"",n[c]=n[c]||h++,n[r]=n[r]||s.disabled,n.$order=n.$order||++g,p.optgroups.push(n),i=n[c],B(s.children,(e=>{m(e,i)}))):"option"===t&&m(e)}))})():(()=>{const t=e.getAttribute(i) +if(t)p.options=JSON.parse(t),B(p.options,(e=>{p.items.push(e[o])})) +else{var r=e.value.trim()||"" +if(!s.allowEmptyOption&&!r.length)return +const t=r.split(s.delimiter) +B(t,(e=>{const t={} +t[n]=e,t[o]=e,p.options.push(t)})),p.items=t}})(),Object.assign({},re,p,t)}var ae=0 +class ce extends(function(e){return e.plugins={},class extends e{constructor(...e){super(...e),this.plugins={names:[],settings:{},requested:{},loaded:{}}}static define(t,s){e.plugins[t]={name:t,fn:s}}initializePlugins(e){var t,s +const i=this,n=[] +if(Array.isArray(e))e.forEach((e=>{"string"==typeof e?n.push(e):(i.plugins.settings[e.name]=e.options,n.push(e.name))})) +else if(e)for(t in e)e.hasOwnProperty(t)&&(i.plugins.settings[t]=e[t],n.push(t)) +for(;s=n.shift();)i.require(s)}loadPlugin(t){var s=this,i=s.plugins,n=e.plugins[t] +if(!e.plugins.hasOwnProperty(t))throw new Error('Unable to find "'+t+'" plugin') +i.requested[t]=!0,i.loaded[t]=n.fn.apply(s,[s.plugins.settings[t]||{}]),i.names.push(t)}require(e){var t=this,s=t.plugins +if(!t.plugins.loaded.hasOwnProperty(e)){if(s.requested[e])throw new Error('Plugin has circular dependency ("'+e+'")') +t.loadPlugin(e)}return s.loaded[e]}}}(t)){constructor(e,t){var s +super(),this.order=0,this.isOpen=!1,this.isDisabled=!1,this.isReadOnly=!1,this.isInvalid=!1,this.isValid=!0,this.isLocked=!1,this.isFocused=!1,this.isInputHidden=!1,this.isSetup=!1,this.ignoreFocus=!1,this.ignoreHover=!1,this.hasOptions=!1,this.lastValue="",this.caretPos=0,this.loading=0,this.loadedSearches={},this.activeOption=null,this.activeItems=[],this.optgroups={},this.options={},this.userOptions={},this.items=[],this.refreshTimeout=null,ae++ +var i=K(e) +if(i.tomselect)throw new Error("Tom Select already initialized on this element") +i.tomselect=this,s=(window.getComputedStyle&&window.getComputedStyle(i,null)).getPropertyValue("direction") +const n=le(i,t) +this.settings=n,this.input=i,this.tabIndex=i.tabIndex||0,this.is_select_tag="select"===i.tagName.toLowerCase(),this.rtl=/rtl/i.test(s),this.inputId=R(i,"tomselect-"+ae),this.isRequired=i.required,this.sifter=new T(this.options,{diacritics:n.diacritics}),n.mode=n.mode||(1===n.maxItems?"single":"multi"),"boolean"!=typeof n.hideSelected&&(n.hideSelected="multi"===n.mode),"boolean"!=typeof n.hidePlaceholder&&(n.hidePlaceholder="multi"!==n.mode) +var o=n.createFilter +"function"!=typeof o&&("string"==typeof o&&(o=new RegExp(o)),o instanceof RegExp?n.createFilter=e=>o.test(e):n.createFilter=e=>this.settings.duplicates||!this.options[e]),this.initializePlugins(n.plugins),this.setupCallbacks(),this.setupTemplates() +const r=K("
"),l=K("
"),a=this._render("dropdown"),c=K('
'),d=this.input.getAttribute("class")||"",u=n.mode +var p +if(J(r,n.wrapperClass,d,u),J(l,n.controlClass),z(r,l),J(a,n.dropdownClass,u),n.copyClassesToDropdown&&J(a,d),J(c,n.dropdownContentClass),z(a,c),K(n.dropdownParent||r).appendChild(a),Q(n.controlInput)){p=K(n.controlInput) +B(["autocorrect","autocapitalize","autocomplete","spellcheck"],(e=>{i.getAttribute(e)&&se(p,{[e]:i.getAttribute(e)})})),p.tabIndex=-1,l.appendChild(p),this.focus_node=p}else n.controlInput?(p=K(n.controlInput),this.focus_node=p):(p=K(""),this.focus_node=l) +this.wrapper=r,this.dropdown=a,this.dropdown_content=c,this.control=l,this.control_input=p,this.setup()}setup(){const e=this,t=e.settings,s=e.control_input,i=e.dropdown,n=e.dropdown_content,o=e.wrapper,l=e.control,a=e.input,c=e.focus_node,d={passive:!0},u=e.inputId+"-ts-dropdown" +se(n,{id:u}),se(c,{role:"combobox","aria-haspopup":"listbox","aria-expanded":"false","aria-controls":u}) +const p=R(c,e.inputId+"-ts-control"),h="label[for='"+(e=>e.replace(/['"\\]/g,"\\$&"))(e.inputId)+"']",g=document.querySelector(h),f=e.focus.bind(e) +if(g){D(g,"click",f),se(g,{for:p}) +const t=R(g,e.inputId+"-ts-label") +se(c,{"aria-labelledby":t}),se(n,{"aria-labelledby":t})}if(o.style.width=a.style.width,e.plugins.names.length){const t="plugin-"+e.plugins.names.join(" plugin-") +J([o,i],t)}(null===t.maxItems||t.maxItems>1)&&e.is_select_tag&&se(a,{multiple:"multiple"}),t.placeholder&&se(s,{placeholder:t.placeholder}),!t.splitOn&&t.delimiter&&(t.splitOn=new RegExp("\\s*"+r(t.delimiter)+"+\\s*")),t.load&&t.loadThrottle&&(t.load=$(t.load,t.loadThrottle)),D(i,"mousemove",(()=>{e.ignoreHover=!1})),D(i,"mouseenter",(t=>{var s=Z(t.target,"[data-selectable]",i) +s&&e.onOptionHover(t,s)}),{capture:!0}),D(i,"click",(t=>{const s=Z(t.target,"[data-selectable]") +s&&(e.onOptionSelect(t,s),q(t,!0))})),D(l,"click",(t=>{var i=Z(t.target,"[data-ts-item]",l) +i&&e.onItemSelect(t,i)?q(t,!0):""==s.value&&(e.onClick(),q(t,!0))})),D(c,"keydown",(t=>e.onKeyDown(t))),D(s,"keypress",(t=>e.onKeyPress(t))),D(s,"input",(t=>e.onInput(t))),D(c,"blur",(t=>e.onBlur(t))),D(c,"focus",(t=>e.onFocus(t))),D(s,"paste",(t=>e.onPaste(t))) +const m=t=>{const n=t.composedPath()[0] +if(!o.contains(n)&&!i.contains(n))return e.isFocused&&e.blur(),void e.inputState() +n==s&&e.isOpen?t.stopPropagation():q(t,!0)},v=()=>{e.isOpen&&e.positionDropdown()} +D(document,"mousedown",m),D(window,"scroll",v,d),D(window,"resize",v,d),this._destroy=()=>{document.removeEventListener("mousedown",m),window.removeEventListener("scroll",v),window.removeEventListener("resize",v),g&&g.removeEventListener("click",f)},this.revertSettings={innerHTML:a.innerHTML,tabIndex:a.tabIndex},a.tabIndex=-1,a.insertAdjacentElement("afterend",e.wrapper),e.sync(!1),t.items=[],delete t.optgroups,delete t.options,D(a,"invalid",(()=>{e.isValid&&(e.isValid=!1,e.isInvalid=!0,e.refreshState())})),e.updateOriginalInput(),e.refreshItems(),e.close(!1),e.inputState(),e.isSetup=!0,a.disabled?e.disable():a.readOnly?e.setReadOnly(!0):e.enable(),e.on("change",this.onChange),J(a,"tomselected","ts-hidden-accessible"),e.trigger("initialize"),!0===t.preload&&e.preload()}setupOptions(e=[],t=[]){this.addOptions(e),B(t,(e=>{this.registerOptionGroup(e)}))}setupTemplates(){var e=this,t=e.settings.labelField,s=e.settings.optgroupLabelField,i={optgroup:e=>{let t=document.createElement("div") +return t.className="optgroup",t.appendChild(e.options),t},optgroup_header:(e,t)=>'
'+t(e[s])+"
",option:(e,s)=>"
"+s(e[t])+"
",item:(e,s)=>"
"+s(e[t])+"
",option_create:(e,t)=>'
Add '+t(e.input)+"…
",no_results:()=>'
No results found
',loading:()=>'
',not_loading:()=>{},dropdown:()=>"
"} +e.settings.render=Object.assign({},i,e.settings.render)}setupCallbacks(){var e,t,s={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",item_select:"onItemSelect",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"} +for(e in s)(t=this.settings[s[e]])&&this.on(e,t)}sync(e=!0){const t=this,s=e?le(t.input,{delimiter:t.settings.delimiter}):t.settings +t.setupOptions(s.options,s.optgroups),t.setValue(s.items||[],!0),t.lastQuery=null}onClick(){var e=this +if(e.activeItems.length>0)return e.clearActiveItems(),void e.focus() +e.isFocused&&e.isOpen?e.blur():e.focus()}onMouseDown(){}onChange(){G(this.input,"input"),G(this.input,"change")}onPaste(e){var t=this +t.isInputHidden||t.isLocked?q(e):t.settings.splitOn&&setTimeout((()=>{var e=t.inputValue() +if(e.match(t.settings.splitOn)){var s=e.trim().split(t.settings.splitOn) +B(s,(e=>{P(e)&&(this.options[e]?t.addItem(e):t.createItem(e))}))}}),0)}onKeyPress(e){var t=this +if(!t.isLocked){var s=String.fromCharCode(e.keyCode||e.which) +return t.settings.create&&"multi"===t.settings.mode&&s===t.settings.delimiter?(t.createItem(),void q(e)):void 0}q(e)}onKeyDown(e){var t=this +if(t.ignoreHover=!0,t.isLocked)9!==e.keyCode&&q(e) +else{switch(e.keyCode){case 65:if(H(oe,e)&&""==t.control_input.value)return q(e),void t.selectAll() +break +case 27:return t.isOpen&&(q(e,!0),t.close()),void t.clearActiveItems() +case 40:if(!t.isOpen&&t.hasOptions)t.open() +else if(t.activeOption){let e=t.getAdjacent(t.activeOption,1) +e&&t.setActiveOption(e)}return void q(e) +case 38:if(t.activeOption){let e=t.getAdjacent(t.activeOption,-1) +e&&t.setActiveOption(e)}return void q(e) +case 13:return void(t.canSelect(t.activeOption)?(t.onOptionSelect(e,t.activeOption),q(e)):(t.settings.create&&t.createItem()||document.activeElement==t.control_input&&t.isOpen)&&q(e)) +case 37:return void t.advanceSelection(-1,e) +case 39:return void t.advanceSelection(1,e) +case 9:return void(t.settings.selectOnTab&&(t.canSelect(t.activeOption)&&(t.onOptionSelect(e,t.activeOption),q(e)),t.settings.create&&t.createItem()&&q(e))) +case 8:case 46:return void t.deleteSelection(e)}t.isInputHidden&&!H(oe,e)&&q(e)}}onInput(e){if(this.isLocked)return +const t=this.inputValue() +this.lastValue!==t&&(this.lastValue=t,""!=t?(this.refreshTimeout&&window.clearTimeout(this.refreshTimeout),this.refreshTimeout=((e,t)=>t>0?window.setTimeout(e,t):(e.call(null),null))((()=>{this.refreshTimeout=null,this._onInput()}),this.settings.refreshThrottle)):this._onInput())}_onInput(){const e=this.lastValue +this.settings.shouldLoad.call(this,e)&&this.load(e),this.refreshOptions(),this.trigger("type",e)}onOptionHover(e,t){this.ignoreHover||this.setActiveOption(t,!1)}onFocus(e){var t=this,s=t.isFocused +if(t.isDisabled||t.isReadOnly)return t.blur(),void q(e) +t.ignoreFocus||(t.isFocused=!0,"focus"===t.settings.preload&&t.preload(),s||t.trigger("focus"),t.activeItems.length||(t.inputState(),t.refreshOptions(!!t.settings.openOnFocus)),t.refreshState())}onBlur(e){if(!1!==document.hasFocus()){var t=this +if(t.isFocused){t.isFocused=!1,t.ignoreFocus=!1 +var s=()=>{t.close(),t.setActiveItem(),t.setCaret(t.items.length),t.trigger("blur")} +t.settings.create&&t.settings.createOnBlur?t.createItem(null,s):s()}}}onOptionSelect(e,t){var s,i=this +t.parentElement&&t.parentElement.matches("[data-disabled]")||(t.classList.contains("create")?i.createItem(null,(()=>{i.settings.closeAfterSelect&&i.close()})):void 0!==(s=t.dataset.value)&&(i.lastQuery=null,i.addItem(s),i.settings.closeAfterSelect&&i.close(),!i.settings.hideSelected&&e.type&&/click/.test(e.type)&&i.setActiveOption(t)))}canSelect(e){return!!(this.isOpen&&e&&this.dropdown_content.contains(e))}onItemSelect(e,t){var s=this +return!s.isLocked&&"multi"===s.settings.mode&&(q(e),s.setActiveItem(t,e),!0)}canLoad(e){return!!this.settings.load&&!this.loadedSearches.hasOwnProperty(e)}load(e){const t=this +if(!t.canLoad(e))return +J(t.wrapper,t.settings.loadingClass),t.loading++ +const s=t.loadCallback.bind(t) +t.settings.load.call(t,e,s)}loadCallback(e,t){const s=this +s.loading=Math.max(s.loading-1,0),s.lastQuery=null,s.clearActiveOption(),s.setupOptions(e,t),s.refreshOptions(s.isFocused&&!s.isInputHidden),s.loading||W(s.wrapper,s.settings.loadingClass),s.trigger("load",e,t)}preload(){var e=this.wrapper.classList +e.contains("preloaded")||(e.add("preloaded"),this.load(""))}setTextboxValue(e=""){var t=this.control_input +t.value!==e&&(t.value=e,G(t,"update"),this.lastValue=e)}getValue(){return this.is_select_tag&&this.input.hasAttribute("multiple")?this.items:this.items.join(this.settings.delimiter)}setValue(e,t){V(this,t?[]:["change"],(()=>{this.clear(t),this.addItems(e,t)}))}setMaxItems(e){0===e&&(e=null),this.settings.maxItems=e,this.refreshState()}setActiveItem(e,t){var s,i,n,o,r,l,a=this +if("single"!==a.settings.mode){if(!e)return a.clearActiveItems(),void(a.isFocused&&a.inputState()) +if("click"===(s=t&&t.type.toLowerCase())&&H("shiftKey",t)&&a.activeItems.length){for(l=a.getLastActive(),(n=Array.prototype.indexOf.call(a.control.children,l))>(o=Array.prototype.indexOf.call(a.control.children,e))&&(r=n,n=o,o=r),i=n;i<=o;i++)e=a.control.children[i],-1===a.activeItems.indexOf(e)&&a.setActiveItemClass(e) +q(t)}else"click"===s&&H(oe,t)||"keydown"===s&&H("shiftKey",t)?e.classList.contains("active")?a.removeActiveItem(e):a.setActiveItemClass(e):(a.clearActiveItems(),a.setActiveItemClass(e)) +a.inputState(),a.isFocused||a.focus()}}setActiveItemClass(e){const t=this,s=t.control.querySelector(".last-active") +s&&W(s,"last-active"),J(e,"active last-active"),t.trigger("item_select",e),-1==t.activeItems.indexOf(e)&&t.activeItems.push(e)}removeActiveItem(e){var t=this.activeItems.indexOf(e) +this.activeItems.splice(t,1),W(e,"active")}clearActiveItems(){W(this.activeItems,"active"),this.activeItems=[]}setActiveOption(e,t=!0){e!==this.activeOption&&(this.clearActiveOption(),e&&(this.activeOption=e,se(this.focus_node,{"aria-activedescendant":e.getAttribute("id")}),se(e,{"aria-selected":"true"}),J(e,"active"),t&&this.scrollToOption(e)))}scrollToOption(e,t){if(!e)return +const s=this.dropdown_content,i=s.clientHeight,n=s.scrollTop||0,o=e.offsetHeight,r=e.getBoundingClientRect().top-s.getBoundingClientRect().top+n +r+o>i+n?this.scroll(r-i+o,t):r{e.setActiveItemClass(t)})))}inputState(){var e=this +e.control.contains(e.control_input)&&(se(e.control_input,{placeholder:e.settings.placeholder}),e.activeItems.length>0||!e.isFocused&&e.settings.hidePlaceholder&&e.items.length>0?(e.setTextboxValue(),e.isInputHidden=!0):(e.settings.hidePlaceholder&&e.items.length>0&&se(e.control_input,{placeholder:""}),e.isInputHidden=!1),e.wrapper.classList.toggle("input-hidden",e.isInputHidden))}inputValue(){return this.control_input.value.trim()}focus(){var e=this +e.isDisabled||e.isReadOnly||(e.ignoreFocus=!0,e.control_input.offsetWidth?e.control_input.focus():e.focus_node.focus(),setTimeout((()=>{e.ignoreFocus=!1,e.onFocus()}),0))}blur(){this.focus_node.blur(),this.onBlur()}getScoreFunction(e){return this.sifter.getScoreFunction(e,this.getSearchOptions())}getSearchOptions(){var e=this.settings,t=e.sortField +return"string"==typeof e.sortField&&(t=[{field:e.sortField}]),{fields:e.searchField,conjunction:e.searchConjunction,sort:t,nesting:e.nesting}}search(e){var t,s,i=this,n=this.getSearchOptions() +if(i.settings.score&&"function"!=typeof(s=i.settings.score.call(i,e)))throw new Error('Tom Select "score" setting must be a function that returns a function') +return e!==i.lastQuery?(i.lastQuery=e,t=i.sifter.search(e,Object.assign(n,{score:s})),i.currentResults=t):t=Object.assign({},i.currentResults),i.settings.hideSelected&&(t.items=t.items.filter((e=>{let t=P(e.id) +return!(t&&-1!==i.items.indexOf(t))}))),t}refreshOptions(e=!0){var t,s,i,n,o,r,l,a,c,d +const u={},p=[] +var h=this,g=h.inputValue() +const f=g===h.lastQuery||""==g&&null==h.lastQuery +var m=h.search(g),v=null,y=h.settings.shouldOpen||!1,O=h.dropdown_content +f&&(v=h.activeOption)&&(c=v.closest("[data-group]")),n=m.items.length,"number"==typeof h.settings.maxOptions&&(n=Math.min(n,h.settings.maxOptions)),n>0&&(y=!0) +const b=(e,t)=>{let s=u[e] +if(void 0!==s){let e=p[s] +if(void 0!==e)return[s,e.fragment]}let i=document.createDocumentFragment() +return s=p.length,p.push({fragment:i,order:t,optgroup:e}),[s,i]} +for(t=0;t0&&(d=d.cloneNode(!0),se(d,{id:l.$id+"-clone-"+s,"aria-selected":null}),d.classList.add("ts-cloned"),W(d,"active"),h.activeOption&&h.activeOption.dataset.value==n&&c&&c.dataset.group===o.toString()&&(v=d)),a.appendChild(d),""!=o&&(u[o]=i)}}var w +h.settings.lockOptgroupOrder&&p.sort(((e,t)=>e.order-t.order)),l=document.createDocumentFragment(),B(p,(e=>{let t=e.fragment,s=e.optgroup +if(!t||!t.children.length)return +let i=h.optgroups[s] +if(void 0!==i){let e=document.createDocumentFragment(),s=h.render("optgroup_header",i) +z(e,s),z(e,t) +let n=h.render("optgroup",{group:i,options:e}) +z(l,n)}else z(l,t)})),O.innerHTML="",z(O,l),h.settings.highlight&&(w=O.querySelectorAll("span.highlight"),Array.prototype.forEach.call(w,(function(e){var t=e.parentNode +t.replaceChild(e.firstChild,e),t.normalize()})),m.query.length&&m.tokens.length&&B(m.tokens,(e=>{ne(O,e.regex)}))) +var _=e=>{let t=h.render(e,{input:g}) +return t&&(y=!0,O.insertBefore(t,O.firstChild)),t} +if(h.loading?_("loading"):h.settings.shouldLoad.call(h,g)?0===m.items.length&&_("no_results"):_("not_loading"),(a=h.canCreate(g))&&(d=_("option_create")),h.hasOptions=m.items.length>0||a,y){if(m.items.length>0){if(v||"single"!==h.settings.mode||null==h.items[0]||(v=h.getOption(h.items[0])),!O.contains(v)){let e=0 +d&&!h.settings.addPrecedence&&(e=1),v=h.selectable()[e]}}else d&&(v=d) +e&&!h.isOpen&&(h.open(),h.scrollToOption(v,"auto")),h.setActiveOption(v)}else h.clearActiveOption(),e&&h.isOpen&&h.close(!1)}selectable(){return this.dropdown_content.querySelectorAll("[data-selectable]")}addOption(e,t=!1){const s=this +if(Array.isArray(e))return s.addOptions(e,t),!1 +const i=P(e[s.settings.valueField]) +return null!==i&&!s.options.hasOwnProperty(i)&&(e.$order=e.$order||++s.order,e.$id=s.inputId+"-opt-"+e.$order,s.options[i]=e,s.lastQuery=null,t&&(s.userOptions[i]=t,s.trigger("option_add",i,e)),i)}addOptions(e,t=!1){B(e,(e=>{this.addOption(e,t)}))}registerOption(e){return this.addOption(e)}registerOptionGroup(e){var t=P(e[this.settings.optgroupValueField]) +return null!==t&&(e.$order=e.$order||++this.order,this.optgroups[t]=e,t)}addOptionGroup(e,t){var s +t[this.settings.optgroupValueField]=e,(s=this.registerOptionGroup(t))&&this.trigger("optgroup_add",s,t)}removeOptionGroup(e){this.optgroups.hasOwnProperty(e)&&(delete this.optgroups[e],this.clearCache(),this.trigger("optgroup_remove",e))}clearOptionGroups(){this.optgroups={},this.clearCache(),this.trigger("optgroup_clear")}updateOption(e,t){const s=this +var i,n +const o=P(e),r=P(t[s.settings.valueField]) +if(null===o)return +const l=s.options[o] +if(null==l)return +if("string"!=typeof r)throw new Error("Value must be set in option data") +const a=s.getOption(o),c=s.getItem(o) +if(t.$order=t.$order||l.$order,delete s.options[o],s.uncacheValue(r),s.options[r]=t,a){if(s.dropdown_content.contains(a)){const e=s._render("option",t) +ie(a,e),s.activeOption===a&&s.setActiveOption(e)}a.remove()}c&&(-1!==(n=s.items.indexOf(o))&&s.items.splice(n,1,r),i=s._render("item",t),c.classList.contains("active")&&J(i,"active"),ie(c,i)),s.lastQuery=null}removeOption(e,t){const s=this +e=N(e),s.uncacheValue(e),delete s.userOptions[e],delete s.options[e],s.lastQuery=null,s.trigger("option_remove",e),s.removeItem(e,t)}clearOptions(e){const t=(e||this.clearFilter).bind(this) +this.loadedSearches={},this.userOptions={},this.clearCache() +const s={} +B(this.options,((e,i)=>{t(e,i)&&(s[i]=e)})),this.options=this.sifter.items=s,this.lastQuery=null,this.trigger("option_clear")}clearFilter(e,t){return this.items.indexOf(t)>=0}getOption(e,t=!1){const s=P(e) +if(null===s)return null +const i=this.options[s] +if(null!=i){if(i.$div)return i.$div +if(t)return this._render("option",i)}return null}getAdjacent(e,t,s="option"){var i +if(!e)return null +i="item"==s?this.controlChildren():this.dropdown_content.querySelectorAll("[data-selectable]") +for(let s=0;s0?i[s+1]:i[s-1] +return null}getItem(e){if("object"==typeof e)return e +var t=P(e) +return null!==t?this.control.querySelector(`[data-value="${M(t)}"]`):null}addItems(e,t){var s=this,i=Array.isArray(e)?e:[e] +const n=(i=i.filter((e=>-1===s.items.indexOf(e))))[i.length-1] +i.forEach((e=>{s.isPending=e!==n,s.addItem(e,t)}))}addItem(e,t){V(this,t?[]:["change","dropdown_close"],(()=>{var s,i +const n=this,o=n.settings.mode,r=P(e) +if((!r||-1===n.items.indexOf(r)||("single"===o&&n.close(),"single"!==o&&n.settings.duplicates))&&null!==r&&n.options.hasOwnProperty(r)&&("single"===o&&n.clear(t),"multi"!==o||!n.isFull())){if(s=n._render("item",n.options[r]),n.control.contains(s)&&(s=s.cloneNode(!0)),i=n.isFull(),n.items.splice(n.caretPos,0,r),n.insertAtCaret(s),n.isSetup){if(!n.isPending&&n.settings.hideSelected){let e=n.getOption(r),t=n.getAdjacent(e,1) +t&&n.setActiveOption(t)}n.isPending||n.settings.closeAfterSelect||n.refreshOptions(n.isFocused&&"single"!==o),0!=n.settings.closeAfterSelect&&n.isFull()?n.close():n.isPending||n.positionDropdown(),n.trigger("item_add",r,s),n.isPending||n.updateOriginalInput({silent:t})}(!n.isPending||!i&&n.isFull())&&(n.inputState(),n.refreshState())}}))}removeItem(e=null,t){const s=this +if(!(e=s.getItem(e)))return +var i,n +const o=e.dataset.value +i=te(e),e.remove(),e.classList.contains("active")&&(n=s.activeItems.indexOf(e),s.activeItems.splice(n,1),W(e,"active")),s.items.splice(i,1),s.lastQuery=null,!s.settings.persist&&s.userOptions.hasOwnProperty(o)&&s.removeOption(o,t),i{}){3===arguments.length&&(t=arguments[2]),"function"!=typeof t&&(t=()=>{}) +var s,i=this,n=i.caretPos +if(e=e||i.inputValue(),!i.canCreate(e))return t(),!1 +i.lock() +var o=!1,r=e=>{if(i.unlock(),!e||"object"!=typeof e)return t() +var s=P(e[i.settings.valueField]) +if("string"!=typeof s)return t() +i.setTextboxValue(),i.addOption(e,!0),i.setCaret(n),i.addItem(s),t(e),o=!0} +return s="function"==typeof i.settings.create?i.settings.create.call(this,e,r):{[i.settings.labelField]:e,[i.settings.valueField]:e},o||r(s),!0}refreshItems(){var e=this +e.lastQuery=null,e.isSetup&&e.addItems(e.items),e.updateOriginalInput(),e.refreshState()}refreshState(){const e=this +e.refreshValidityState() +const t=e.isFull(),s=e.isLocked +e.wrapper.classList.toggle("rtl",e.rtl) +const i=e.wrapper.classList +var n +i.toggle("focus",e.isFocused),i.toggle("disabled",e.isDisabled),i.toggle("readonly",e.isReadOnly),i.toggle("required",e.isRequired),i.toggle("invalid",!e.isValid),i.toggle("locked",s),i.toggle("full",t),i.toggle("input-active",e.isFocused&&!e.isInputHidden),i.toggle("dropdown-active",e.isOpen),i.toggle("has-options",(n=e.options,0===Object.keys(n).length)),i.toggle("has-items",e.items.length>0)}refreshValidityState(){var e=this +e.input.validity&&(e.isValid=e.input.validity.valid,e.isInvalid=!e.isValid)}isFull(){return null!==this.settings.maxItems&&this.items.length>=this.settings.maxItems}updateOriginalInput(e={}){const t=this +var s,i +const n=t.input.querySelector('option[value=""]') +if(t.is_select_tag){const o=[],r=t.input.querySelectorAll("option:checked").length +function l(e,s,i){return e||(e=K('")),e!=n&&t.input.append(e),o.push(e),(e!=n||r>0)&&(e.selected=!0),e}t.input.querySelectorAll("option:checked").forEach((e=>{e.selected=!1})),0==t.items.length&&"single"==t.settings.mode?l(n,"",""):t.items.forEach((e=>{if(s=t.options[e],i=s[t.settings.labelField]||"",o.includes(s.$option)){l(t.input.querySelector(`option[value="${M(e)}"]:not(:checked)`),e,i)}else s.$option=l(s.$option,e,i)}))}else t.input.value=t.getValue() +t.isSetup&&(e.silent||t.trigger("change",t.getValue()))}open(){var e=this +e.isLocked||e.isOpen||"multi"===e.settings.mode&&e.isFull()||(e.isOpen=!0,se(e.focus_node,{"aria-expanded":"true"}),e.refreshState(),U(e.dropdown,{visibility:"hidden",display:"block"}),e.positionDropdown(),U(e.dropdown,{visibility:"visible",display:"block"}),e.focus(),e.trigger("dropdown_open",e.dropdown))}close(e=!0){var t=this,s=t.isOpen +e&&(t.setTextboxValue(),"single"===t.settings.mode&&t.items.length&&t.inputState()),t.isOpen=!1,se(t.focus_node,{"aria-expanded":"false"}),U(t.dropdown,{display:"none"}),t.settings.hideSelected&&t.clearActiveOption(),t.refreshState(),s&&t.trigger("dropdown_close",t.dropdown)}positionDropdown(){if("body"===this.settings.dropdownParent){var e=this.control,t=e.getBoundingClientRect(),s=e.offsetHeight+t.top+window.scrollY,i=t.left+window.scrollX +U(this.dropdown,{width:t.width+"px",top:s+"px",left:i+"px"})}}clear(e){var t=this +if(t.items.length){var s=t.controlChildren() +B(s,(e=>{t.removeItem(e,!0)})),t.inputState(),e||t.updateOriginalInput(),t.trigger("clear")}}insertAtCaret(e){const t=this,s=t.caretPos,i=t.control +i.insertBefore(e,i.children[s]||null),t.setCaret(s+1)}deleteSelection(e){var t,s,i,n,o,r=this +t=e&&8===e.keyCode?-1:1,s={start:(o=r.control_input).selectionStart||0,length:(o.selectionEnd||0)-(o.selectionStart||0)} +const l=[] +if(r.activeItems.length)n=ee(r.activeItems,t),i=te(n),t>0&&i++,B(r.activeItems,(e=>l.push(e))) +else if((r.isFocused||"single"===r.settings.mode)&&r.items.length){const e=r.controlChildren() +let i +t<0&&0===s.start&&0===s.length?i=e[r.caretPos-1]:t>0&&s.start===r.inputValue().length&&(i=e[r.caretPos]),void 0!==i&&l.push(i)}if(!r.shouldDelete(l,e))return!1 +for(q(e,!0),void 0!==i&&r.setCaret(i);l.length;)r.removeItem(l.pop()) +return r.inputState(),r.positionDropdown(),r.refreshOptions(!1),!0}shouldDelete(e,t){const s=e.map((e=>e.dataset.value)) +return!(!s.length||"function"==typeof this.settings.onDelete&&!1===this.settings.onDelete(s,t))}advanceSelection(e,t){var s,i,n=this +n.rtl&&(e*=-1),n.inputValue().length||(H(oe,t)||H("shiftKey",t)?(i=(s=n.getLastActive(e))?s.classList.contains("active")?n.getAdjacent(s,e,"item"):s:e>0?n.control_input.nextElementSibling:n.control_input.previousElementSibling)&&(i.classList.contains("active")&&n.removeActiveItem(s),n.setActiveItemClass(i)):n.moveCaret(e))}moveCaret(e){}getLastActive(e){let t=this.control.querySelector(".last-active") +if(t)return t +var s=this.control.querySelectorAll(".active") +return s?ee(s,e):void 0}setCaret(e){this.caretPos=this.items.length}controlChildren(){return Array.from(this.control.querySelectorAll("[data-ts-item]"))}lock(){this.setLocked(!0)}unlock(){this.setLocked(!1)}setLocked(e=this.isReadOnly||this.isDisabled){this.isLocked=e,this.refreshState()}disable(){this.setDisabled(!0),this.close()}enable(){this.setDisabled(!1)}setDisabled(e){this.focus_node.tabIndex=e?-1:this.tabIndex,this.isDisabled=e,this.input.disabled=e,this.control_input.disabled=e,this.setLocked()}setReadOnly(e){this.isReadOnly=e,this.input.readOnly=e,this.control_input.readOnly=e,this.setLocked()}destroy(){var e=this,t=e.revertSettings +e.trigger("destroy"),e.off(),e.wrapper.remove(),e.dropdown.remove(),e.input.innerHTML=t.innerHTML,e.input.tabIndex=t.tabIndex,W(e.input,"tomselected","ts-hidden-accessible"),e._destroy(),delete e.input.tomselect}render(e,t){var s,i +const n=this +if("function"!=typeof this.settings.render[e])return null +if(!(i=n.settings.render[e].call(this,t,j)))return null +if(i=K(i),"option"===e||"option_create"===e?t[n.settings.disabledField]?se(i,{"aria-disabled":"true"}):se(i,{"data-selectable":""}):"optgroup"===e&&(s=t.group[n.settings.optgroupValueField],se(i,{"data-group":s}),t.group[n.settings.disabledField]&&se(i,{"data-disabled":""})),"option"===e||"item"===e){const s=N(t[n.settings.valueField]) +se(i,{"data-value":s}),"item"===e?(J(i,n.settings.itemClass),se(i,{"data-ts-item":""})):(J(i,n.settings.optionClass),se(i,{role:"option",id:t.$id}),t.$div=i,n.options[s]=t)}return i}_render(e,t){const s=this.render(e,t) +if(null==s)throw"HTMLElement expected" +return s}clearCache(){B(this.options,(e=>{e.$div&&(e.$div.remove(),delete e.$div)}))}uncacheValue(e){const t=this.getOption(e) +t&&t.remove()}canCreate(e){return this.settings.create&&e.length>0&&this.settings.createFilter.call(this,e)}hook(e,t,s){var i=this,n=i[t] +i[t]=function(){var t,o +return"after"===e&&(t=n.apply(i,arguments)),o=s.apply(i,arguments),"instead"===e?o:("before"===e&&(t=n.apply(i,arguments)),t)}}}return ce.define("change_listener",(function(){D(this.input,"change",(()=>{this.sync()}))})),ce.define("checkbox_options",(function(e){var t=this,s=t.onOptionSelect +t.settings.hideSelected=!1 +const i=Object.assign({className:"tomselect-checkbox",checkedClassNames:void 0,uncheckedClassNames:void 0},e) +var n=function(e,t){t?(e.checked=!0,i.uncheckedClassNames&&e.classList.remove(...i.uncheckedClassNames),i.checkedClassNames&&e.classList.add(...i.checkedClassNames)):(e.checked=!1,i.checkedClassNames&&e.classList.remove(...i.checkedClassNames),i.uncheckedClassNames&&e.classList.add(...i.uncheckedClassNames))},o=function(e){setTimeout((()=>{var t=e.querySelector("input."+i.className) +t instanceof HTMLInputElement&&n(t,e.classList.contains("selected"))}),1)} +t.hook("after","setupTemplates",(()=>{var e=t.settings.render.option +t.settings.render.option=(s,o)=>{var r=K(e.call(t,s,o)),l=document.createElement("input") +i.className&&l.classList.add(i.className),l.addEventListener("click",(function(e){q(e)})),l.type="checkbox" +const a=P(s[t.settings.valueField]) +return n(l,!!(a&&t.items.indexOf(a)>-1)),r.prepend(l),r}})),t.on("item_remove",(e=>{var s=t.getOption(e) +s&&(s.classList.remove("selected"),o(s))})),t.on("item_add",(e=>{var s=t.getOption(e) +s&&o(s)})),t.hook("instead","onOptionSelect",((e,i)=>{if(i.classList.contains("selected"))return i.classList.remove("selected"),t.removeItem(i.dataset.value),t.refreshOptions(),void q(e,!0) +s.call(t,e,i),o(i)}))})),ce.define("clear_button",(function(e){const t=this,s=Object.assign({className:"clear-button",title:"Clear All",html:e=>`
⨯
`},e) +t.on("initialize",(()=>{var e=K(s.html(s)) +e.addEventListener("click",(e=>{t.isLocked||(t.clear(),"single"===t.settings.mode&&t.settings.allowEmptyOption&&t.addItem(""),e.preventDefault(),e.stopPropagation())})),t.control.appendChild(e)}))})),ce.define("drag_drop",(function(){var e=this +if("multi"!==e.settings.mode)return +var t=e.lock,s=e.unlock +let i,n=!0 +e.hook("after","setupTemplates",(()=>{var t=e.settings.render.item +e.settings.render.item=(s,o)=>{const r=K(t.call(e,s,o)) +se(r,{draggable:"true"}) +const l=e=>{e.preventDefault(),r.classList.add("ts-drag-over"),a(r,i)},a=(e,t)=>{var s,i,n +void 0!==t&&(((e,t)=>{do{var s +if(e==(t=null==(s=t)?void 0:s.previousElementSibling))return!0}while(t&&t.previousElementSibling) +return!1})(t,r)?(i=t,null==(n=(s=e).parentNode)||n.insertBefore(i,s.nextSibling)):((e,t)=>{var s +null==(s=e.parentNode)||s.insertBefore(t,e)})(e,t))} +return D(r,"mousedown",(e=>{n||q(e),e.stopPropagation()})),D(r,"dragstart",(e=>{i=r,setTimeout((()=>{r.classList.add("ts-dragging")}),0)})),D(r,"dragenter",l),D(r,"dragover",l),D(r,"dragleave",(()=>{r.classList.remove("ts-drag-over")})),D(r,"dragend",(()=>{var t +document.querySelectorAll(".ts-drag-over").forEach((e=>e.classList.remove("ts-drag-over"))),null==(t=i)||t.classList.remove("ts-dragging"),i=void 0 +var s=[] +e.control.querySelectorAll("[data-value]").forEach((e=>{if(e.dataset.value){let t=e.dataset.value +t&&s.push(t)}})),e.setValue(s)})),r}})),e.hook("instead","lock",(()=>(n=!1,t.call(e)))),e.hook("instead","unlock",(()=>(n=!0,s.call(e))))})),ce.define("dropdown_header",(function(e){const t=this,s=Object.assign({title:"Untitled",headerClass:"dropdown-header",titleRowClass:"dropdown-header-title",labelClass:"dropdown-header-label",closeClass:"dropdown-header-close",html:e=>'
'+e.title+'×
'},e) +t.on("initialize",(()=>{var e=K(s.html(s)),i=e.querySelector("."+s.closeClass) +i&&i.addEventListener("click",(e=>{q(e,!0),t.close()})),t.dropdown.insertBefore(e,t.dropdown.firstChild)}))})),ce.define("caret_position",(function(){var e=this +e.hook("instead","setCaret",(t=>{"single"!==e.settings.mode&&e.control.contains(e.control_input)?(t=Math.max(0,Math.min(e.items.length,t)))==e.caretPos||e.isPending||e.controlChildren().forEach(((s,i)=>{i{if(!e.isFocused)return +const s=e.getLastActive(t) +if(s){const i=te(s) +e.setCaret(t>0?i+1:i),e.setActiveItem(),W(s,"last-active")}else e.setCaret(e.caretPos+t)}))})),ce.define("dropdown_input",(function(){const e=this +e.settings.shouldOpen=!0,e.hook("before","setup",(()=>{e.focus_node=e.control,J(e.control_input,"dropdown-input") +const t=K('