Skip to content

Commit 07d1bc6

Browse files
committed
Fix dashboard admin actions over HTTP
1 parent b448665 commit 07d1bc6

4 files changed

Lines changed: 24 additions & 12 deletions

File tree

src/dashboard.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ export function dashboardHtml(initialConfig: unknown = null): string {
2121
}
2222
*{box-sizing:border-box} body{margin:0;background:linear-gradient(180deg,var(--paper),#cfc6b2);color:var(--ink);font-family:var(--sans);font-size:14px;}
2323
body::before{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;background-image:linear-gradient(rgba(62,58,50,.12) 1px,transparent 1px),linear-gradient(90deg,rgba(62,58,50,.12) 1px,transparent 1px);background-size:18px 18px;mix-blend-mode:multiply;}
24-
.wrap{position:relative;z-index:1;width:min(100%,1040px);margin:0 auto;padding:12px 10px 80px}.top{display:flex;gap:6px;align-items:flex-start;justify-content:space-between;margin-bottom:10px}.brand{border-top:2px solid var(--ink);padding-top:8px;min-width:0}.eyebrow{display:inline-block;font:700 11px/1 var(--mono);letter-spacing:.12em;color:var(--muted);text-transform:uppercase;text-decoration:none}.eyebrow:hover{color:var(--ink)}.brand h1{font-size:19px;line-height:1.05;margin:6px 0 0;letter-spacing:-.055em;white-space:nowrap}.status{min-width:96px;text-align:right;font-family:var(--mono);font-size:11px}.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:#d8cfbb;padding:5px 7px}.dot{width:8px;height:8px;border-radius:99px;background:var(--disabled)}.dot.on{background:var(--good)}.dot.off{background:var(--bad)}
25-
.grid{display:grid;grid-template-columns:1fr;gap:10px}.bind-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(92px,1fr);gap:8px;align-items:end}.bridge-key{margin:8px 0 0}.bridge-key-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:0;align-items:center}.bridge-key-prefix{min-height:38px;display:inline-flex;align-items:center;border:1px solid var(--line);border-right:0;background:#d8cfbb;padding:0 8px;font-family:var(--mono);font-weight:700}.bridge-key-row input{min-width:0;border-left:0}.bridge-key-help-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px;align-items:center;margin-top:6px}.bridge-key-help{font:700 11px/1.2 var(--mono);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bridge-key-help-row button{min-height:34px;min-width:36px;padding:5px 8px}.concurrency-row .info{justify-self:end;margin-right:10px}.card{background:#c9bfaa;border:1px solid var(--line);box-shadow:inset 0 1px rgba(255,255,255,.32);padding:10px}.card h2{display:flex;align-items:center;justify-content:space-between;font-size:13px;letter-spacing:.04em;text-transform:uppercase;margin:0 0 8px;border-bottom:1px solid var(--line);padding-bottom:6px}.sub{color:var(--muted);font-size:12px}.row{display:flex;gap:8px;align-items:center;justify-content:space-between;margin:6px 0}.stack{display:grid;gap:7px}.field{display:grid;gap:4px}.field label{font:700 10px/1 var(--mono);letter-spacing:.08em;color:var(--muted);text-transform:uppercase}input,select,button{font:inherit}input,select{width:100%;min-height:38px;border:1px solid var(--line);background:#ddd4c0;color:var(--ink);padding:8px}button{min-height:40px;border:1px solid var(--ink);background:var(--ink);color:var(--paper);padding:8px 10px;text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:700}button.secondary{background:#d8cfbb;color:var(--ink);border-color:var(--line)}button.danger{background:var(--bad);border-color:var(--bad)}button:disabled{background:var(--disabled);border-color:var(--disabled);opacity:.55}.seg{display:grid;grid-template-columns:1fr;gap:6px}.one-line-field{display:flex;gap:8px;align-items:center;margin-top:8px}.one-line-field label{font-weight:700;white-space:nowrap}.one-line-field input{width:74px;text-align:right}.concurrency-row{display:grid;grid-template-columns:auto 74px auto 1fr auto;gap:8px;align-items:center}.concurrency-spacer{min-width:0}.info{position:relative;font-size:16px;line-height:1;cursor:help;color:var(--muted);display:inline-flex}.info .tip{display:none;position:absolute;right:0;top:24px;z-index:5;width:min(72vw,320px);border:1px solid var(--line);background:#e0d7c3;color:var(--ink);box-shadow:0 8px 18px rgba(48,45,39,.22);padding:8px;font:12px/1.45 var(--sans)}.info:hover .tip,.info:focus .tip{display:block}.policy{position:relative;border:1px solid var(--line);padding:9px 10px;background:#d8cfbb;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center}.policy input{width:auto;min-height:0}.policy b{display:block;font-size:13px}.kv{display:grid;grid-template-columns:1fr auto;gap:4px;font-family:var(--mono);font-size:11px;border-bottom:1px dashed rgba(80,72,60,.35);padding:4px 0}.model,.cred{border:1px solid var(--line);background:#d8cfbb;padding:8px;display:grid;gap:7px}.cred-name{min-height:34px;font-weight:700;background:#d8cfbb}.model-head,.cred-head{display:flex;gap:8px;align-items:center;justify-content:space-between}.switch{position:relative;width:48px;height:26px;flex:0 0 auto}.switch input{display:none}.slider{position:absolute;inset:0;border:1px solid var(--line);background:var(--disabled)}.slider:before{content:"";position:absolute;width:20px;height:20px;left:2px;top:2px;background:var(--paper)}.switch input:checked+.slider{background:var(--good)}.switch input:checked+.slider:before{transform:translateX(22px)}.footerbar{position:fixed;left:0;right:0;bottom:0;background:rgba(48,45,39,.96);border-top:1px solid var(--line);padding:8px 10px calc(8px + env(safe-area-inset-bottom));display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;z-index:30}.restart.active{background:#d9b95f;color:var(--ink);border-color:#d9b95f}.small{font-size:11px;color:var(--muted)}.token{font-family:var(--mono);font-size:11px;color:var(--paper)}.toast{position:fixed;left:10px;right:10px;bottom:calc(72px + env(safe-area-inset-bottom));background:var(--ink);color:var(--paper);padding:10px;transform:translateY(120%);transition:.2s;z-index:20;pointer-events:none}.toast.show{transform:translateY(0)}
26-
@media(max-width:520px){.wrap{padding-bottom:112px}.footerbar{grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:stretch}.footerbar .token{grid-column:1/-1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.footerbar button{width:100%;min-width:0}.toast{bottom:calc(112px + env(safe-area-inset-bottom))}}
24+
.wrap{position:relative;z-index:1;width:min(100%,1040px);margin:0 auto;padding:12px 10px 16px}.top{display:flex;gap:6px;align-items:flex-start;justify-content:space-between;margin-bottom:10px}.brand{border-top:2px solid var(--ink);padding-top:8px;min-width:0}.eyebrow{display:inline-block;font:700 11px/1 var(--mono);letter-spacing:.12em;color:var(--muted);text-transform:uppercase;text-decoration:none}.eyebrow:hover{color:var(--ink)}.brand h1{font-size:19px;line-height:1.05;margin:6px 0 0;letter-spacing:-.055em;white-space:nowrap}.status{min-width:96px;text-align:right;font-family:var(--mono);font-size:11px}.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:#d8cfbb;padding:5px 7px}.dot{width:8px;height:8px;border-radius:99px;background:var(--disabled)}.dot.on{background:var(--good)}.dot.off{background:var(--bad)}
25+
.grid{display:grid;grid-template-columns:1fr;gap:10px}.bind-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(92px,1fr);gap:8px;align-items:end}.bridge-key{margin:8px 0 0}.bridge-key-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:0;align-items:center}.bridge-key-prefix{min-height:38px;display:inline-flex;align-items:center;border:1px solid var(--line);border-right:0;background:#d8cfbb;padding:0 8px;font-family:var(--mono);font-weight:700}.bridge-key-row input{min-width:0;border-left:0}.bridge-key-help-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px;align-items:center;margin-top:6px}.bridge-key-help{font:700 11px/1.2 var(--mono);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bridge-key-help-row button{min-height:34px;min-width:36px;padding:5px 8px}.concurrency-row .info{justify-self:end;margin-right:10px}.card{background:#c9bfaa;border:1px solid var(--line);box-shadow:inset 0 1px rgba(255,255,255,.32);padding:10px}.card h2{display:flex;align-items:center;justify-content:space-between;font-size:13px;letter-spacing:.04em;text-transform:uppercase;margin:0 0 8px;border-bottom:1px solid var(--line);padding-bottom:6px}.sub{color:var(--muted);font-size:12px}.row{display:flex;gap:8px;align-items:center;justify-content:space-between;margin:6px 0}.stack{display:grid;gap:7px}.field{display:grid;gap:4px}.field label{font:700 10px/1 var(--mono);letter-spacing:.08em;color:var(--muted);text-transform:uppercase}input,select,button{font:inherit}input,select{width:100%;min-height:38px;border:1px solid var(--line);background:#ddd4c0;color:var(--ink);padding:8px}button{min-height:40px;border:1px solid var(--ink);background:var(--ink);color:var(--paper);padding:8px 10px;text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:700}button.secondary{background:#d8cfbb;color:var(--ink);border-color:var(--line)}button.danger{background:var(--bad);border-color:var(--bad)}button:disabled{background:var(--disabled);border-color:var(--disabled);opacity:.55}.seg{display:grid;grid-template-columns:1fr;gap:6px}.one-line-field{display:flex;gap:8px;align-items:center;margin-top:8px}.one-line-field label{font-weight:700;white-space:nowrap}.one-line-field input{width:74px;text-align:right}.concurrency-row{display:grid;grid-template-columns:auto 74px auto 1fr auto;gap:8px;align-items:center}.concurrency-spacer{min-width:0}.info{position:relative;font-size:16px;line-height:1;cursor:help;color:var(--muted);display:inline-flex}.info .tip{display:none;position:absolute;right:0;top:24px;z-index:5;width:min(72vw,320px);border:1px solid var(--line);background:#e0d7c3;color:var(--ink);box-shadow:0 8px 18px rgba(48,45,39,.22);padding:8px;font:12px/1.45 var(--sans)}.info:hover .tip,.info:focus .tip{display:block}.policy{position:relative;border:1px solid var(--line);padding:9px 10px;background:#d8cfbb;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center}.policy input{width:auto;min-height:0}.policy b{display:block;font-size:13px}.kv{display:grid;grid-template-columns:1fr auto;gap:4px;font-family:var(--mono);font-size:11px;border-bottom:1px dashed rgba(80,72,60,.35);padding:4px 0}.model,.cred{border:1px solid var(--line);background:#d8cfbb;padding:8px;display:grid;gap:7px}.cred-name{min-height:34px;font-weight:700;background:#d8cfbb}.model-head,.cred-head{display:flex;gap:8px;align-items:center;justify-content:space-between}.switch{position:relative;width:48px;height:26px;flex:0 0 auto}.switch input{display:none}.slider{position:absolute;inset:0;border:1px solid var(--line);background:var(--disabled)}.slider:before{content:"";position:absolute;width:20px;height:20px;left:2px;top:2px;background:var(--paper)}.switch input:checked+.slider{background:var(--good)}.switch input:checked+.slider:before{transform:translateX(22px)}.footerbar{position:relative;margin-top:10px;background:rgba(48,45,39,.96);border:1px solid var(--line);padding:8px;display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center}.restart.active{background:#d9b95f;color:var(--ink);border-color:#d9b95f}.small{font-size:11px;color:var(--muted)}.token{font-family:var(--mono);font-size:11px;color:var(--paper)}.toast{position:fixed;left:10px;right:10px;top:10px;background:var(--ink);color:var(--paper);padding:10px;transform:translateY(-140%);transition:.2s;z-index:20;pointer-events:none}.toast.show{transform:translateY(0)}
26+
@media(max-width:520px){.footerbar{grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:stretch}.footerbar .token{grid-column:1/-1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.footerbar button{width:100%;min-width:0}}
2727
@media(max-width:360px){.brand h1{font-size:17px}.status{min-width:88px}}
28-
@media(min-width:760px){.wrap{padding:22px 18px 90px}.grid{grid-template-columns:1.1fr .9fr}.wide{grid-column:1/-1}.brand h1{font-size:27px}.models{grid-template-columns:repeat(3,1fr);display:grid}.creds{grid-template-columns:repeat(2,1fr);display:grid}.top{align-items:center}}
28+
@media(min-width:760px){.wrap{padding:22px 18px 22px}.grid{grid-template-columns:1.1fr .9fr}.wide{grid-column:1/-1}.brand h1{font-size:27px}.models{grid-template-columns:repeat(3,1fr);display:grid}.creds{grid-template-columns:repeat(2,1fr);display:grid}.top{align-items:center}}
2929
</style>
3030
</head>
3131
<body>
@@ -40,8 +40,8 @@ export function dashboardHtml(initialConfig: unknown = null): string {
4040
<div class="card wide"><h2>Credentials <span class="row"><button id="refreshCreds" class="secondary">Refresh</button><button id="addCred" class="secondary">Add key</button></span></h2><div id="creds" class="stack creds"></div></div>
4141
<div class="card wide"><h2>Models <span class="sub">on/off after restart</span></h2><div id="models" class="stack models"></div></div>
4242
</section>
43+
<div class="footerbar"><span id="dirtyText" class="token">no pending changes</span><button id="save" class="secondary">Save JSON</button><button id="restart" class="restart" disabled>Restart Bridge</button></div>
4344
</main>
44-
<div class="footerbar"><span id="dirtyText" class="token">no pending changes</span><button id="save" class="secondary">Save JSON</button><button id="restart" class="restart" disabled>Restart Bridge</button></div>
4545
<div id="toast" class="toast"></div>
4646
<script>
4747
const policies=[['daily_burn_priority','잔액/남은일수 우선','만료 전 써야 할 크레딧을 먼저 태움'],['balance_priority','잔액 많은 순','현재 잔액이 큰 key 우선'],['round_robin','순환 분산','요청마다 key를 순서대로 선택'],['drain_first','앞 key 소진','1번 key부터 다 쓰고 다음 key로 이동']];
@@ -66,8 +66,8 @@ $('creds').innerHTML=cfg.credentials.map((c,i)=>{c.originalId=c.originalId||c.id
6666
document.querySelectorAll('[data-cid]').forEach(e=>e.oninput=()=>{cfg.credentials[+e.dataset.cid].id=e.value;setDirty();}); document.querySelectorAll('[data-cenabled]').forEach(e=>e.onchange=()=>{cfg.credentials[+e.dataset.cenabled].enabled=e.checked;setDirty();}); document.querySelectorAll('[data-ckey]').forEach(e=>e.oninput=()=>{cfg.credentials[+e.dataset.ckey].apiKey=e.value;setDirty();}); document.querySelectorAll('[data-del]').forEach(e=>e.onclick=()=>{cfg.credentials.splice(+e.dataset.del,1);render();setDirty();});
6767
$('models').innerHTML=cfg.models.map((m,i)=>'<div class="model"><div class="model-head"><div><b>'+esc(m.label||m.id)+'</b><div class="small">'+esc(m.provider)+' · '+esc(m.id)+'</div></div><label class="switch"><input data-mid="'+i+'" type="checkbox" '+(m.enabled?'checked':'')+'><span class="slider"></span></label></div><div class="small">'+esc(m.notes||'')+'</div></div>').join(''); document.querySelectorAll('[data-mid]').forEach(e=>e.onchange=()=>{cfg.models[+e.dataset.mid].enabled=e.checked;setDirty();});}
6868
$('bindHost').onchange=()=>{cfg.server.host=$('bindHost').value;syncBridgeKey();setDirty();}; $('bindPort').oninput=()=>{cfg.server.port=Number($('bindPort').value)||9992;setDirty();}; function saveBridgeKey(){const key=fullBridgeKey(); if(key)localStorage.setItem('bridgeApiKey',key); else localStorage.removeItem('bridgeApiKey'); syncBridgeKey(); toast(key?'Admin API key saved in this browser.':'Admin API key cleared.');} async function copyBridgeKey(){const key=fullBridgeKey()||localStorage.getItem('bridgeApiKey')||''; if(!key){toast('Admin API key is empty.');return;} try{await navigator.clipboard.writeText(key);toast('Admin API key copied.');}catch{toast('Copy failed. Select and copy manually.');}} $('saveBridgeKey').onclick=saveBridgeKey; $('copyBridgeKey').onclick=copyBridgeKey; $('bridgeApiKey').onkeydown=e=>{if(e.key==='Enter')saveBridgeKey();}; $('maxPer').oninput=()=>{cfg.routing.maxInFlightPerCredential=Number($('maxPer').value)||4; setDirty();}; $('refreshCreds').onclick=async()=>{try{const m=await api('/admin/commandcode/credentials?refresh=true'); const byId=new Map((m.credentials||[]).map(x=>[x.id,x])); cfg.credentials.forEach(c=>c.metrics=byId.get(c.id)); render(); toast('Credentials refreshed.');}catch(e){toast('Credential refresh failed.');}}; $('addCred').onclick=()=>{cfg.credentials.push({id:'key'+(cfg.credentials.length+1),apiKey:'',weight:1,enabled:true});render();setDirty();};
69-
$('save').onclick=async()=>{const payload={server:cfg.server,routing:cfg.routing,models:cfg.models,credentials:cfg.credentials.map(c=>({id:c.id,originalId:c.originalId,apiKey:c.apiKey||undefined,weight:c.weight||1,enabled:expiredIds.has(c.id)?undefined:c.enabled!==false,maxInFlight:c.maxInFlight,allowedModels:c.allowedModels}))}; cfg=await api('/admin/config',{method:'PUT',body:JSON.stringify(payload)}); render(); setDirty(true); toast('JSON saved. Restart required.');};
70-
$('restart').onclick=async()=>{await api('/admin/restart',{method:'POST',body:'{}'}); toast('Restart requested'); setTimeout(load,1800);};
69+
$('save').onclick=async()=>{try{if(!cfg)throw new Error('config not loaded'); const payload={server:cfg.server,routing:cfg.routing,models:cfg.models,credentials:cfg.credentials.map(c=>({id:c.id,originalId:c.originalId,apiKey:c.apiKey||undefined,weight:c.weight||1,enabled:expiredIds.has(c.id)?undefined:c.enabled!==false,maxInFlight:c.maxInFlight,allowedModels:c.allowedModels}))}; cfg=await api('/admin/config',{method:'PUT',body:JSON.stringify(payload)}); render(); setDirty(true); toast('JSON saved. Restart required.');}catch(e){toast('Save failed: '+(e?.message||e));}};
70+
$('restart').onclick=async()=>{try{await api('/admin/restart',{method:'POST',body:'{}'}); toast('Restart requested'); setTimeout(load,1800);}catch(e){toast('Restart failed: '+(e?.message||e));}};
7171
load();
7272
</script>
7373
</body></html>`;

src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,8 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Fastify
297297
contentSecurityPolicy: {
298298
directives: {
299299
scriptSrc: ["'self'", "'unsafe-inline'"],
300+
connectSrc: ["'self'", "http:"],
301+
upgradeInsecureRequests: null,
300302
},
301303
},
302304
});

tests/dashboard-ui.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,19 +75,19 @@ describe("dashboard UI", () => {
7575
expect(html.indexOf("data-cenabled")).toBeLessThan(html.indexOf("data-del"));
7676
});
7777

78-
it("keeps toast notifications from blocking the floating save/restart bar", () => {
78+
it("keeps save/restart actions in normal document flow with no overlay hit-target", () => {
7979
const html = dashboardHtml({
8080
server: { host: "127.0.0.1", port: 9992 },
8181
routing: { policy: "daily_burn_priority", maxInFlightPerCredential: 4 },
8282
credentials: [],
8383
models: [],
8484
});
8585

86-
expect(html).toContain(".footerbar{position:fixed");
87-
expect(html).toContain("z-index:30");
86+
expect(html).toContain(".footerbar{position:relative");
87+
expect(html).not.toContain(".footerbar{position:fixed");
8888
expect(html).toContain(".toast{position:fixed");
89-
expect(html).toContain("z-index:20;pointer-events:none");
90-
expect(html).toContain("env(safe-area-inset-bottom)");
89+
expect(html).toContain("top:10px");
90+
expect(html).toContain("pointer-events:none");
9191
expect(html).toContain("@media(max-width:520px)");
9292
expect(html).toContain(".footerbar{grid-template-columns:minmax(0,1fr) minmax(0,1fr)");
9393
expect(html).toContain(".footerbar .token{grid-column:1/-1");

tests/server.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,16 @@ describe("Fastify OpenAI-compatible server", () => {
133133
await app.close();
134134
});
135135

136+
it("serves dashboard CSP without upgrading HTTP admin calls to HTTPS", async () => {
137+
const app = await createTestApp({ upstream: new FakeCommandCodeClient() });
138+
const response = await app.inject({ method: "GET", url: "/dashboard" });
139+
const csp = String(response.headers["content-security-policy"] ?? "");
140+
expect(response.statusCode).toBe(200);
141+
expect(csp).toContain("connect-src 'self' http:");
142+
expect(csp).not.toContain("upgrade-insecure-requests");
143+
await app.close();
144+
});
145+
136146
it("requires bridge API key when configured", async () => {
137147
const app = await createTestApp({
138148
upstream: new FakeCommandCodeClient(),

0 commit comments

Comments
 (0)