@cartanova/qgrid-cli 2.6.1 → 2.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/bundle/dist/application/qgrid/oauth.js +21 -3
- package/bundle/dist/application/qgrid/qgrid.frame.js +31 -18
- package/bundle/dist/application/qgrid/token-death.js +52 -0
- package/bundle/dist/application/queries.generated.js +2 -2
- package/bundle/dist/application/request-log/request-log.model.js +69 -27
- package/bundle/dist/application/sonamu.generated.js +20 -3
- package/bundle/dist/application/sonamu.generated.sso.js +18 -2
- package/bundle/dist/application/token/token.model.js +42 -1
- package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +49 -0
- package/bundle/dist/migrations/20260807145144_alter_request_logs_alter1.js +13 -0
- package/bundle/dist/utils/providers/openai/openai-refresh.js +12 -1
- package/bundle/dist/utils/slack-notify.js +65 -0
- package/bundle/src/application/qgrid/oauth.ts +20 -1
- package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +18 -3
- package/bundle/src/application/qgrid/qgrid.frame.ts +58 -47
- package/bundle/src/application/qgrid/token-death.test.ts +120 -0
- package/bundle/src/application/qgrid/token-death.ts +67 -0
- package/bundle/src/application/queries.generated.ts +2 -2
- package/bundle/src/application/request-log/request-log.entity.json +20 -6
- package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
- package/bundle/src/application/request-log/request-log.model.ts +86 -40
- package/bundle/src/application/sonamu.generated.http +13 -1
- package/bundle/src/application/sonamu.generated.sso.ts +16 -0
- package/bundle/src/application/sonamu.generated.ts +16 -1
- package/bundle/src/application/token/token.model.test.ts +39 -0
- package/bundle/src/application/token/token.model.ts +77 -0
- package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +110 -0
- package/bundle/src/migrations/20260807145144_alter_request_logs_alter1.ts +13 -0
- package/bundle/src/utils/providers/openai/openai-refresh.ts +17 -0
- package/bundle/src/utils/slack-notify.test.ts +97 -0
- package/bundle/src/utils/slack-notify.ts +74 -0
- package/bundle/web-dist/client/assets/index-2cAgaLOm.css +1 -0
- package/bundle/web-dist/client/assets/{index-BGGBFmzE.js → index-BgWzzjvj.js} +3 -3
- package/bundle/web-dist/client/assets/logs-Lyv8OfHE.js +1 -0
- package/bundle/web-dist/client/assets/{monit-CyqMmy1G.js → monit-BSa5lSZp.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-Cy0gCq8c.js → routes-Cp0re-Nn.js} +2 -2
- package/bundle/web-dist/client/assets/{services.generated-BuT6Gp8V.js → services.generated-B-dMUtEu.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C5WxfoAt.js → show-C1jEPhuS.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-1e66MAC0.js +1 -0
- package/bundle/web-dist/client/assets/{use-update-token-mutation-DBkxL29Y.js → use-update-token-mutation-DueEppqt.js} +1 -1
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{logs-D0z1Ghj1.js → logs-CwoOsveV.js} +11 -4
- package/bundle/web-dist/server/assets/{monit-dBXxnAyI.js → monit-DIsEsDJN.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-B3p7fuwk.js → routes-Bts-KJ9W.js} +7 -3
- package/bundle/web-dist/server/assets/{services.generated-BNuxWs7K.js → services.generated-Bh7Vs8Ds.js} +7 -7
- package/bundle/web-dist/server/assets/{show-0iNioJ2I.js → show-DJfaCNUw.js} +1 -1
- package/bundle/web-dist/server/assets/{tokens-CvytQ9Xy.js → tokens-BA4vwDat.js} +206 -108
- package/bundle/web-dist/server/assets/{use-update-token-mutation-BBCC2GKM.js → use-update-token-mutation-BQRzEPy2.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +6 -6
- package/package.json +1 -1
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +11 -0
- package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
- package/bundle/web-dist/client/assets/logs-Dh19u6Tk.js +0 -1
- package/bundle/web-dist/client/assets/tokens-Ciolqw4l.js +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Z as __toESM, _ as require_jsx_runtime, g as useQueryClient, v as require_react } from "./sd.generated-ek-tz-7H.js";
|
|
2
|
-
import { a as TokenService, n as QgridService } from "./services.generated-
|
|
2
|
+
import { a as TokenService, n as QgridService } from "./services.generated-Bh7Vs8Ds.js";
|
|
3
3
|
import { i as Q, o as lucideKeyRound } from "../entry-server.generated.js";
|
|
4
|
-
import { t as useUpdateTokenMutation } from "./use-update-token-mutation-
|
|
4
|
+
import { t as useUpdateTokenMutation } from "./use-update-token-mutation-BQRzEPy2.js";
|
|
5
5
|
//#region ~icons/lucide/plus.jsx
|
|
6
6
|
var import_react = /* @__PURE__ */ __toESM(require_react());
|
|
7
7
|
var import_jsx_runtime = require_jsx_runtime();
|
|
@@ -20,77 +20,168 @@ var lucidePlus = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", {
|
|
|
20
20
|
})
|
|
21
21
|
});
|
|
22
22
|
//#endregion
|
|
23
|
-
//#region src/components/qgrid/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
//#region src/components/qgrid/OAuthCodeEntry.tsx
|
|
24
|
+
/**
|
|
25
|
+
* 원격 접속(code 모드) Claude 로그인의 코드 입력 단계.
|
|
26
|
+
* Add Token 모달과 재로그인이 같은 문구·에러·재시작 동선을 쓰도록 한 곳에 둔다.
|
|
27
|
+
*/
|
|
28
|
+
function OAuthCodeEntry({ isPending, isError, onSubmit, onRestart }) {
|
|
29
29
|
const [pastedCode, setPastedCode] = (0, import_react.useState)("");
|
|
30
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
31
|
+
className: "space-y-2",
|
|
32
|
+
children: [
|
|
33
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
|
|
34
|
+
className: "text-[12px] text-sand-600 leading-relaxed",
|
|
35
|
+
children: "새 탭에서 Claude 로그인을 완료하면 코드가 표시됩니다. 코드 전체를 아래에 붙여넣으세요."
|
|
36
|
+
}),
|
|
37
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Q, {
|
|
38
|
+
value: pastedCode,
|
|
39
|
+
onChange: (e) => setPastedCode(e.target.value),
|
|
40
|
+
placeholder: "code#state",
|
|
41
|
+
className: "w-full border border-sand-200 rounded-md px-3 py-2 text-sm font-mono text-sand-900 bg-white placeholder:text-sand-300 focus:outline-none focus:border-sienna-300"
|
|
42
|
+
}),
|
|
43
|
+
isError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
|
|
44
|
+
className: "text-[11px] text-red-500",
|
|
45
|
+
children: "코드 확인에 실패했습니다 — 표시된 코드 전체를 다시 붙여넣거나 로그인을 다시 시작하세요."
|
|
46
|
+
}),
|
|
47
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
|
|
48
|
+
type: "button",
|
|
49
|
+
className: "w-full py-2.5 text-sm font-medium rounded-md bg-sand-900 text-white hover:bg-sand-800 disabled:opacity-50 transition-colors duration-150 flex items-center justify-center gap-2",
|
|
50
|
+
disabled: !pastedCode.trim() || isPending,
|
|
51
|
+
onClick: () => onSubmit(pastedCode),
|
|
52
|
+
children: isPending ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
53
|
+
className: "flex items-center gap-1.5",
|
|
54
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" }), "Verifying..."]
|
|
55
|
+
}) : "Complete login"
|
|
56
|
+
}),
|
|
57
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
|
|
58
|
+
type: "button",
|
|
59
|
+
className: "w-full py-1.5 text-[12px] text-sand-500 hover:text-sand-700 transition-colors duration-150",
|
|
60
|
+
onClick: () => {
|
|
61
|
+
setPastedCode("");
|
|
62
|
+
onRestart();
|
|
63
|
+
},
|
|
64
|
+
children: "처음부터 다시 시작"
|
|
65
|
+
})
|
|
66
|
+
]
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/components/qgrid/use-oauth-login-flow.ts
|
|
71
|
+
var OPENAI_POLL_INTERVAL_MS = 3e3;
|
|
72
|
+
var OPENAI_POLL_TIMEOUT_MS = 3e5;
|
|
73
|
+
/** 캐시에 적재된 토큰 행 수. 폴링 조기 종료 판정에만 쓰는 근사치다. */
|
|
74
|
+
function countTokens(queryClient) {
|
|
75
|
+
let total = 0;
|
|
76
|
+
for (const query of queryClient.getQueryCache().findAll({ queryKey: ["Token"] })) {
|
|
77
|
+
const rows = query.state.data?.rows;
|
|
78
|
+
if (Array.isArray(rows)) total = Math.max(total, rows.length);
|
|
79
|
+
}
|
|
80
|
+
return total;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* provider별 OAuth 로그인 시작과 코드 제출을 한 곳에 모은다.
|
|
84
|
+
* Add Token 모달과 tokens 목록의 재로그인 버튼이 같은 플로우를 쓴다 — 두 진입점이
|
|
85
|
+
* redirect/code 분기나 폴링 처리를 각자 구현하면 원격 접속 경험이 갈라진다.
|
|
86
|
+
*/
|
|
87
|
+
function useOAuthLoginFlow() {
|
|
88
|
+
const [loadingProvider, setLoadingProvider] = (0, import_react.useState)(null);
|
|
89
|
+
const [codeEntry, setCodeEntry] = (0, import_react.useState)(false);
|
|
30
90
|
const queryClient = useQueryClient();
|
|
31
91
|
const oauthStartMutation = QgridService.useOauthStartMutation();
|
|
32
92
|
const oauthStartOpenAIMutation = QgridService.useOauthStartOpenAIMutation();
|
|
33
93
|
const oauthCompleteMutation = QgridService.useOauthCompleteMutation();
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
94
|
+
const pollTimers = (0, import_react.useRef)({});
|
|
95
|
+
const stopPolling = () => {
|
|
96
|
+
if (pollTimers.current.interval !== void 0) clearInterval(pollTimers.current.interval);
|
|
97
|
+
if (pollTimers.current.timeout !== void 0) clearTimeout(pollTimers.current.timeout);
|
|
98
|
+
pollTimers.current = {};
|
|
99
|
+
};
|
|
100
|
+
(0, import_react.useEffect)(() => stopPolling, []);
|
|
101
|
+
const invalidateTokens = () => Promise.all([queryClient.invalidateQueries({ queryKey: ["Token"] }), queryClient.invalidateQueries({ queryKey: ["Qgrid"] })]);
|
|
102
|
+
const start = async (provider, name) => {
|
|
103
|
+
const trimmed = name.trim();
|
|
104
|
+
if (!trimmed) return;
|
|
105
|
+
const popup = window.open("about:blank", "_blank");
|
|
106
|
+
setLoadingProvider(provider);
|
|
107
|
+
try {
|
|
108
|
+
if (provider === "openai") {
|
|
109
|
+
const { authUrl } = await oauthStartOpenAIMutation.mutateAsync({ name: trimmed });
|
|
41
110
|
if (popup) popup.location.href = authUrl;
|
|
42
111
|
else window.open(authUrl, "_blank");
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
112
|
+
const before = countTokens(queryClient);
|
|
113
|
+
stopPolling();
|
|
114
|
+
pollTimers.current.interval = window.setInterval(() => {
|
|
115
|
+
invalidateTokens().then(() => {
|
|
116
|
+
if (countTokens(queryClient) > before) {
|
|
117
|
+
stopPolling();
|
|
118
|
+
setLoadingProvider(null);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}, OPENAI_POLL_INTERVAL_MS);
|
|
122
|
+
pollTimers.current.timeout = window.setTimeout(() => {
|
|
123
|
+
stopPolling();
|
|
124
|
+
setLoadingProvider(null);
|
|
125
|
+
}, OPENAI_POLL_TIMEOUT_MS);
|
|
126
|
+
return;
|
|
54
127
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
else window.open(authUrl, "_blank");
|
|
63
|
-
setCodeEntry(true);
|
|
64
|
-
setOauthLoading(null);
|
|
65
|
-
} else {
|
|
66
|
-
popup?.close();
|
|
67
|
-
window.location.href = authUrl;
|
|
68
|
-
}
|
|
69
|
-
} catch (e) {
|
|
128
|
+
const { authUrl, mode } = await oauthStartMutation.mutateAsync({ name: trimmed });
|
|
129
|
+
if (mode === "code") {
|
|
130
|
+
if (popup) popup.location.href = authUrl;
|
|
131
|
+
else window.open(authUrl, "_blank");
|
|
132
|
+
setCodeEntry(true);
|
|
133
|
+
setLoadingProvider(null);
|
|
134
|
+
} else {
|
|
70
135
|
popup?.close();
|
|
71
|
-
|
|
72
|
-
setOauthLoading(null);
|
|
136
|
+
window.location.href = authUrl;
|
|
73
137
|
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
popup?.close();
|
|
140
|
+
console.error("OAuth start failed:", e);
|
|
141
|
+
setLoadingProvider(null);
|
|
74
142
|
}
|
|
75
143
|
};
|
|
76
|
-
|
|
77
|
-
|
|
144
|
+
/** @returns 교환 성공 여부. 실패 시 `completeMutation.isError` 로 안내 문구를 띄운다. */
|
|
145
|
+
const submitCode = async (pastedCode) => {
|
|
146
|
+
const trimmed = pastedCode.trim();
|
|
147
|
+
if (!trimmed) return false;
|
|
78
148
|
try {
|
|
79
|
-
await oauthCompleteMutation.mutateAsync({ pastedCode:
|
|
149
|
+
await oauthCompleteMutation.mutateAsync({ pastedCode: trimmed });
|
|
80
150
|
} catch (e) {
|
|
81
151
|
console.error("OAuth complete failed:", e);
|
|
82
|
-
return;
|
|
152
|
+
return false;
|
|
83
153
|
}
|
|
84
|
-
await
|
|
85
|
-
|
|
154
|
+
await invalidateTokens();
|
|
155
|
+
return true;
|
|
156
|
+
};
|
|
157
|
+
const reset = () => {
|
|
158
|
+
stopPolling();
|
|
159
|
+
setLoadingProvider(null);
|
|
160
|
+
setCodeEntry(false);
|
|
161
|
+
oauthCompleteMutation.reset();
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
loadingProvider,
|
|
165
|
+
codeEntry,
|
|
166
|
+
start,
|
|
167
|
+
submitCode,
|
|
168
|
+
reset,
|
|
169
|
+
completeMutation: oauthCompleteMutation
|
|
86
170
|
};
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/components/qgrid/AddTokenModal.tsx
|
|
174
|
+
function AddTokenModal() {
|
|
175
|
+
const [open, setOpen] = (0, import_react.useState)(false);
|
|
176
|
+
const [name, setName] = (0, import_react.useState)("");
|
|
177
|
+
const oauth = useOAuthLoginFlow();
|
|
87
178
|
const close = () => {
|
|
88
179
|
setOpen(false);
|
|
89
180
|
setName("");
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
181
|
+
oauth.reset();
|
|
182
|
+
};
|
|
183
|
+
const submitPastedCode = async (pastedCode) => {
|
|
184
|
+
if (await oauth.submitCode(pastedCode)) close();
|
|
94
185
|
};
|
|
95
186
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", {
|
|
96
187
|
type: "button",
|
|
@@ -124,53 +215,20 @@ function AddTokenModal() {
|
|
|
124
215
|
onChange: (e) => setName(e.target.value),
|
|
125
216
|
placeholder: "e.g. your-token-name",
|
|
126
217
|
className: "mt-1 w-full border border-sand-200 rounded-md px-3 py-2 text-sm text-sand-900 bg-white placeholder:text-sand-300 focus:outline-none focus:border-sienna-300"
|
|
127
|
-
})] }), codeEntry ? /* @__PURE__ */ (0, import_jsx_runtime.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
children: "새 탭에서 Claude 로그인을 완료하면 코드가 표시됩니다. 코드 전체를 아래에 붙여넣으세요."
|
|
133
|
-
}),
|
|
134
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Q, {
|
|
135
|
-
value: pastedCode,
|
|
136
|
-
onChange: (e) => setPastedCode(e.target.value),
|
|
137
|
-
placeholder: "code#state",
|
|
138
|
-
className: "w-full border border-sand-200 rounded-md px-3 py-2 text-sm font-mono text-sand-900 bg-white placeholder:text-sand-300 focus:outline-none focus:border-sienna-300"
|
|
139
|
-
}),
|
|
140
|
-
oauthCompleteMutation.isError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
|
|
141
|
-
className: "text-[11px] text-red-500",
|
|
142
|
-
children: "코드 확인에 실패했습니다 — 표시된 코드 전체를 다시 붙여넣거나 로그인을 다시 시작하세요."
|
|
143
|
-
}),
|
|
144
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
|
|
145
|
-
type: "button",
|
|
146
|
-
className: "w-full py-2.5 text-sm font-medium rounded-md bg-sand-900 text-white hover:bg-sand-800 disabled:opacity-50 transition-colors duration-150 flex items-center justify-center gap-2",
|
|
147
|
-
disabled: !pastedCode.trim() || oauthCompleteMutation.isPending,
|
|
148
|
-
onClick: submitPastedCode,
|
|
149
|
-
children: oauthCompleteMutation.isPending ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
150
|
-
className: "flex items-center gap-1.5",
|
|
151
|
-
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" }), "Verifying..."]
|
|
152
|
-
}) : "Complete login"
|
|
153
|
-
}),
|
|
154
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
|
|
155
|
-
type: "button",
|
|
156
|
-
className: "w-full py-1.5 text-[12px] text-sand-500 hover:text-sand-700 transition-colors duration-150",
|
|
157
|
-
onClick: () => {
|
|
158
|
-
setCodeEntry(false);
|
|
159
|
-
setPastedCode("");
|
|
160
|
-
oauthCompleteMutation.reset();
|
|
161
|
-
},
|
|
162
|
-
children: "처음부터 다시 시작"
|
|
163
|
-
})
|
|
164
|
-
]
|
|
218
|
+
})] }), oauth.codeEntry ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(OAuthCodeEntry, {
|
|
219
|
+
isPending: oauth.completeMutation.isPending,
|
|
220
|
+
isError: oauth.completeMutation.isError,
|
|
221
|
+
onSubmit: (code) => void submitPastedCode(code),
|
|
222
|
+
onRestart: oauth.reset
|
|
165
223
|
}) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
166
224
|
className: "space-y-2",
|
|
167
225
|
children: [
|
|
168
226
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
|
|
169
227
|
type: "button",
|
|
170
228
|
className: "w-full py-2.5 text-sm font-medium rounded-md bg-sand-900 text-white hover:bg-sand-800 disabled:opacity-50 transition-colors duration-150 flex items-center justify-center gap-2",
|
|
171
|
-
disabled: !name.trim() ||
|
|
172
|
-
onClick: () =>
|
|
173
|
-
children:
|
|
229
|
+
disabled: !name.trim() || oauth.loadingProvider !== null,
|
|
230
|
+
onClick: () => void oauth.start("openai", name),
|
|
231
|
+
children: oauth.loadingProvider === "openai" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
174
232
|
className: "flex items-center gap-1.5",
|
|
175
233
|
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" }), "Waiting for login..."]
|
|
176
234
|
}) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucideKeyRound, { className: "size-4" }), "Login with OpenAI"] })
|
|
@@ -189,9 +247,9 @@ function AddTokenModal() {
|
|
|
189
247
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
|
|
190
248
|
type: "button",
|
|
191
249
|
className: "w-full py-2.5 text-sm font-medium rounded-md border border-sand-300 text-sand-700 hover:bg-sand-100 disabled:opacity-50 transition-colors duration-150 flex items-center justify-center gap-2",
|
|
192
|
-
disabled: !name.trim() ||
|
|
193
|
-
onClick: () =>
|
|
194
|
-
children:
|
|
250
|
+
disabled: !name.trim() || oauth.loadingProvider !== null,
|
|
251
|
+
onClick: () => void oauth.start("anthropic", name),
|
|
252
|
+
children: oauth.loadingProvider === "anthropic" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
195
253
|
className: "flex items-center gap-1.5",
|
|
196
254
|
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "w-3.5 h-3.5 border-2 border-sand-400 border-t-transparent rounded-full animate-spin" }), "Waiting for login..."]
|
|
197
255
|
}) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucideKeyRound, { className: "size-4" }), "Login with Claude"] })
|
|
@@ -287,6 +345,7 @@ function TokenTable({ data, isLoading }) {
|
|
|
287
345
|
const removeMutation = QgridService.useRemoveTokenMutation();
|
|
288
346
|
const updateMutation = useUpdateTokenMutation();
|
|
289
347
|
const toggleMutation = QgridService.useToggleTokenMutation();
|
|
348
|
+
const oauth = useOAuthLoginFlow();
|
|
290
349
|
const invalidate = () => Promise.all([queryClient.invalidateQueries({ queryKey: ["Token"] }), queryClient.invalidateQueries({ queryKey: ["Qgrid"] })]);
|
|
291
350
|
const handleToggle = async (token) => {
|
|
292
351
|
await toggleMutation.mutateAsync({ id: token.id });
|
|
@@ -298,6 +357,9 @@ function TokenTable({ data, isLoading }) {
|
|
|
298
357
|
await invalidate();
|
|
299
358
|
setDeleteTarget(null);
|
|
300
359
|
};
|
|
360
|
+
const submitReloginCode = async (pastedCode) => {
|
|
361
|
+
if (await oauth.submitCode(pastedCode)) oauth.reset();
|
|
362
|
+
};
|
|
301
363
|
const openEdit = (token) => {
|
|
302
364
|
setEditTarget(token);
|
|
303
365
|
setEditName(token.name ?? "");
|
|
@@ -409,17 +471,28 @@ function TokenTable({ data, isLoading }) {
|
|
|
409
471
|
className: "px-3 py-3",
|
|
410
472
|
children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
411
473
|
className: "flex items-center gap-1",
|
|
412
|
-
children: [
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
474
|
+
children: [
|
|
475
|
+
!token.active && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
|
|
476
|
+
type: "button",
|
|
477
|
+
title: "기존 이름으로 다시 로그인",
|
|
478
|
+
className: "px-2 py-0.5 mr-1 text-[11px] font-medium rounded-md border border-sand-300 text-sand-600 hover:bg-sand-100 disabled:opacity-50 transition-colors duration-150 inline-flex items-center gap-1",
|
|
479
|
+
disabled: oauth.loadingProvider !== null,
|
|
480
|
+
onClick: () => void oauth.start(token.provider, token.name ?? ""),
|
|
481
|
+
children: oauth.loadingProvider === token.provider ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "w-3 h-3 border-2 border-sand-400 border-t-transparent rounded-full animate-spin" }), "로그인 대기"] }) : "재로그인"
|
|
482
|
+
}),
|
|
483
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
|
|
484
|
+
type: "button",
|
|
485
|
+
className: "p-1 rounded text-sand-400 hover:text-sienna-500 transition-colors duration-150",
|
|
486
|
+
onClick: () => openEdit(token),
|
|
487
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucidePencil, { className: "size-4" })
|
|
488
|
+
}),
|
|
489
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
|
|
490
|
+
type: "button",
|
|
491
|
+
className: "p-1 rounded text-sand-400 hover:text-red-500 transition-colors duration-150",
|
|
492
|
+
onClick: () => setDeleteTarget(token),
|
|
493
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucideTrash2, { className: "size-4" })
|
|
494
|
+
})
|
|
495
|
+
]
|
|
423
496
|
})
|
|
424
497
|
})
|
|
425
498
|
]
|
|
@@ -428,6 +501,31 @@ function TokenTable({ data, isLoading }) {
|
|
|
428
501
|
})]
|
|
429
502
|
})
|
|
430
503
|
}),
|
|
504
|
+
oauth.codeEntry && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
505
|
+
className: "fixed inset-0 z-50 flex items-start justify-center pt-[15vh]",
|
|
506
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
507
|
+
className: "absolute inset-0 bg-sand-900/8 backdrop-blur-sm",
|
|
508
|
+
onClick: oauth.reset,
|
|
509
|
+
onKeyDown: () => {}
|
|
510
|
+
}), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
511
|
+
className: "relative panel shadow-xl w-full max-w-md mx-4",
|
|
512
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
513
|
+
className: "px-5 py-4 border-b border-sand-100/60",
|
|
514
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", {
|
|
515
|
+
className: "text-base font-medium text-sand-900",
|
|
516
|
+
children: "재로그인"
|
|
517
|
+
})
|
|
518
|
+
}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
519
|
+
className: "px-5 py-4",
|
|
520
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(OAuthCodeEntry, {
|
|
521
|
+
isPending: oauth.completeMutation.isPending,
|
|
522
|
+
isError: oauth.completeMutation.isError,
|
|
523
|
+
onSubmit: (code) => void submitReloginCode(code),
|
|
524
|
+
onRestart: oauth.reset
|
|
525
|
+
})
|
|
526
|
+
})]
|
|
527
|
+
})]
|
|
528
|
+
}),
|
|
431
529
|
editTarget && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
432
530
|
className: "fixed inset-0 z-50 flex items-start justify-center pt-[15vh]",
|
|
433
531
|
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as QgridService, o as useMutation } from "./services.generated-
|
|
1
|
+
import { n as QgridService, o as useMutation } from "./services.generated-Bh7Vs8Ds.js";
|
|
2
2
|
//#region src/services/token/use-update-token-mutation.ts
|
|
3
3
|
function useUpdateTokenMutation() {
|
|
4
4
|
return useMutation({ mutationFn: ({ id, name, quotaThreshold, weight }) => QgridService.updateToken(id, name, quotaThreshold, weight) });
|
|
@@ -22703,7 +22703,7 @@ var sections = [{
|
|
|
22703
22703
|
}
|
|
22704
22704
|
]
|
|
22705
22705
|
}];
|
|
22706
|
-
var qgridVersion = "2.6.
|
|
22706
|
+
var qgridVersion = "2.6.2";
|
|
22707
22707
|
function Sidebar({ className }) {
|
|
22708
22708
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
22709
22709
|
const isActive = (path) => {
|
|
@@ -44584,11 +44584,11 @@ function RootComponent() {
|
|
|
44584
44584
|
}
|
|
44585
44585
|
//#endregion
|
|
44586
44586
|
//#region src/routes/tokens.tsx
|
|
44587
|
-
var $$splitComponentImporter$4 = () => import("./assets/tokens-
|
|
44587
|
+
var $$splitComponentImporter$4 = () => import("./assets/tokens-BA4vwDat.js");
|
|
44588
44588
|
var Route$4 = createFileRoute("/tokens")({ component: lazyRouteComponent($$splitComponentImporter$4, "component") });
|
|
44589
44589
|
//#endregion
|
|
44590
44590
|
//#region src/routes/monit.tsx
|
|
44591
|
-
var $$splitComponentImporter$3 = () => import("./assets/monit-
|
|
44591
|
+
var $$splitComponentImporter$3 = () => import("./assets/monit-DIsEsDJN.js");
|
|
44592
44592
|
var monitSearchSchema = object({ level: _enum([
|
|
44593
44593
|
"all",
|
|
44594
44594
|
"scaling",
|
|
@@ -44601,7 +44601,7 @@ var Route$3 = createFileRoute("/monit")({
|
|
|
44601
44601
|
});
|
|
44602
44602
|
//#endregion
|
|
44603
44603
|
//#region src/routes/logs.tsx
|
|
44604
|
-
var $$splitComponentImporter$2 = () => import("./assets/logs-
|
|
44604
|
+
var $$splitComponentImporter$2 = () => import("./assets/logs-CwoOsveV.js");
|
|
44605
44605
|
var logsSearchSchema = object({
|
|
44606
44606
|
token: string().optional(),
|
|
44607
44607
|
project: string().optional(),
|
|
@@ -44613,11 +44613,11 @@ var Route$2 = createFileRoute("/logs")({
|
|
|
44613
44613
|
});
|
|
44614
44614
|
//#endregion
|
|
44615
44615
|
//#region src/routes/index.tsx
|
|
44616
|
-
var $$splitComponentImporter$1 = () => import("./assets/routes-
|
|
44616
|
+
var $$splitComponentImporter$1 = () => import("./assets/routes-Bts-KJ9W.js");
|
|
44617
44617
|
var Route$1 = createFileRoute("/")({ component: lazyRouteComponent($$splitComponentImporter$1, "component") });
|
|
44618
44618
|
//#endregion
|
|
44619
44619
|
//#region src/routes/requests/show.tsx
|
|
44620
|
-
var $$splitComponentImporter = () => import("./assets/show-
|
|
44620
|
+
var $$splitComponentImporter = () => import("./assets/show-DJfaCNUw.js");
|
|
44621
44621
|
var showSearchSchema = object({ id: number() });
|
|
44622
44622
|
var Route = createFileRoute("/requests/show")({
|
|
44623
44623
|
validateSearch: showSearchSchema,
|
package/package.json
CHANGED
|
@@ -201,6 +201,17 @@ OpenAI token changes can create, update, deactivate, or kill persistent Codex wo
|
|
|
201
201
|
|
|
202
202
|
Anthropic token changes only affect the in-memory token pool because every request fresh-spawns Claude Code.
|
|
203
203
|
|
|
204
|
+
## Auth-Dead Detection And Token Registration
|
|
205
|
+
|
|
206
|
+
When a refresh fails in a way that only re-login can fix, qgrid removes the token from routing instead of letting every subsequent request 401.
|
|
207
|
+
|
|
208
|
+
- Detection lives at the existing refresh failure points, not a background scanner. OpenAI classifies on the error code (`refresh_token_expired`, `refresh_token_reused`, `refresh_token_invalidated`) regardless of HTTP status; Anthropic treats refresh 400/401 as auth-dead.
|
|
209
|
+
- Before deactivating, qgrid re-reads the token and confirms the refresh token used by the failed attempt still matches the stored one. Anthropic refresh tokens rotate, so a late retry carrying an already-rotated token is a normal race, not a death — those are logged and skipped. `QgridFrame.refreshToken` also dedups concurrent refreshes per token.
|
|
210
|
+
- Deactivation is one conditional `active=true → false` update. The process whose update affects a row is the only one that sends the Slack death notification, so a shared database produces exactly one alert per event.
|
|
211
|
+
- The provider's last active token is never auto-deactivated. A systemic failure (client_id revocation, OAuth contract change) would otherwise empty the pool; qgrid logs an error and keeps the token instead.
|
|
212
|
+
- The other notification is registration: a login whose account has no existing token sends one alert. Re-login replaces an existing row through the account dedup and stays silent, as does manually toggling a token back to active — neither is a new account joining the pool. When the provider response carries no account identifier, dedup is skipped entirely, so qgrid cannot tell new from returning and stays silent; it warns instead, making the unreplaced row diagnosable.
|
|
213
|
+
- Notifications are fail-open: unset `SLACK_BOT_TOKEN`/`SLACK_CHANNEL_ID` is a silent no-op, and send failures (including HTTP 200 with `ok:false`) only warn. Payloads carry an internal reason code such as `anthropic:400`, never the raw provider response body.
|
|
214
|
+
|
|
204
215
|
## Quota Threshold Semantics
|
|
205
216
|
|
|
206
217
|
`quota_threshold` is a routing gate, not a hard external-provider guarantee.
|