adds user-specific overrides for daily quota refresh
This commit is contained in:
+1
-1
@@ -129,7 +129,7 @@ also significantly reduce hash rates on mobile devices.
|
|||||||
- Intel Core i9-13900K (Chrome, in VM limited to 4 cores): 12.2 - 13.0 H/s
|
- Intel Core i9-13900K (Chrome, in VM limited to 4 cores): 12.2 - 13.0 H/s
|
||||||
- iPad Pro (M2) (Safari, 6 workers): 8.0 - 10 H/s
|
- iPad Pro (M2) (Safari, 6 workers): 8.0 - 10 H/s
|
||||||
- Thermal throttles early. 8 cores is normal concurrency, but unstable.
|
- Thermal throttles early. 8 cores is normal concurrency, but unstable.
|
||||||
- iPhone 13 Pro (Safari): 4.0 - 4.6 H/s
|
- iPhone 15 Pro Max (Safari): 4.0 - 4.6 H/s
|
||||||
- Samsung Galaxy S10e (Chrome): 3.6 - 3.8 H/s
|
- Samsung Galaxy S10e (Chrome): 3.6 - 3.8 H/s
|
||||||
- This is a 2019 phone almost matching an iPhone five years newer because of
|
- This is a 2019 phone almost matching an iPhone five years newer because of
|
||||||
bad Safari performance.
|
bad Safari performance.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<legend>Bulk Quota Management</legend>
|
<legend>Bulk Quota Management</legend>
|
||||||
<p>
|
<p>
|
||||||
<button id="refresh-quotas" type="button" onclick="submitForm('resetQuotas')">Refresh All Quotas</button>
|
<button id="refresh-quotas" type="button" onclick="submitForm('resetQuotas')">Refresh All Quotas</button>
|
||||||
Resets all users' quotas to the values set in the <code>TOKEN_QUOTA_*</code> environment variables.
|
Immediately refreshes all users' quotas by the configured amounts.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<button id="clear-token-counts" type="button" onclick="submitForm('resetCounts')">Clear All Token Counts</button>
|
<button id="clear-token-counts" type="button" onclick="submitForm('resetCounts')">Clear All Token Counts</button>
|
||||||
|
|||||||
@@ -101,6 +101,10 @@
|
|||||||
<% ["nickname", "type", "disabledAt", "disabledReason", "maxIps", "adminNote"].forEach(function (key) { %>
|
<% ["nickname", "type", "disabledAt", "disabledReason", "maxIps", "adminNote"].forEach(function (key) { %>
|
||||||
<input type="hidden" name="<%- key %>" value="<%- user[key] %>" />
|
<input type="hidden" name="<%- key %>" value="<%- user[key] %>" />
|
||||||
<% }); %>
|
<% }); %>
|
||||||
|
<!-- tokenRefresh_ keys are dynamically generated -->
|
||||||
|
<% Object.entries(quota).forEach(([family]) => { %>
|
||||||
|
<input type="hidden" name="tokenRefresh_<%- family %>" value="<%- user.tokenRefresh[family] || quota[family] %>" />
|
||||||
|
<% }); %>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h3>Quota Information</h3>
|
<h3>Quota Information</h3>
|
||||||
@@ -111,7 +115,7 @@
|
|||||||
<button type="submit" class="btn btn-primary">Refresh Quotas for User</button>
|
<button type="submit" class="btn btn-primary">Refresh Quotas for User</button>
|
||||||
</form>
|
</form>
|
||||||
<% } %>
|
<% } %>
|
||||||
<%- include("partials/shared_quota-info", { quota, user }) %>
|
<%- include("partials/shared_quota-info", { quota, user, showRefreshEdit: true }) %>
|
||||||
|
|
||||||
<p><a href="/admin/manage/list-users">Back to User List</a></p>
|
<p><a href="/admin/manage/list-users">Back to User List</a></p>
|
||||||
|
|
||||||
@@ -122,18 +126,25 @@
|
|||||||
const token = a.dataset.token;
|
const token = a.dataset.token;
|
||||||
const field = a.dataset.field;
|
const field = a.dataset.field;
|
||||||
const existingValue = document.querySelector(`#current-values input[name=${field}]`).value;
|
const existingValue = document.querySelector(`#current-values input[name=${field}]`).value;
|
||||||
let value = prompt(`Enter new value for '${field}'':`, existingValue);
|
|
||||||
|
let value = prompt(`Enter new value for '${field}':`, existingValue);
|
||||||
if (value !== null) {
|
if (value !== null) {
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
value = null;
|
value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = { _csrf: document.querySelector("meta[name=csrf-token]").getAttribute("content") };
|
||||||
|
if (field.startsWith("tokenRefresh_")) {
|
||||||
|
const family = field.slice("tokenRefresh_".length);
|
||||||
|
payload.tokenRefresh = { [family]: Number(value) };
|
||||||
|
} else {
|
||||||
|
payload[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
fetch(`/admin/manage/edit-user/${token}`, {
|
fetch(`/admin/manage/edit-user/${token}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
[field]: value,
|
|
||||||
_csrf: document.querySelector("meta[name=csrf-token]").getAttribute("content"),
|
|
||||||
}),
|
|
||||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
})
|
})
|
||||||
.then((res) => Promise.all([res.ok, res.json()]))
|
.then((res) => Promise.all([res.ok, res.json()]))
|
||||||
@@ -141,9 +152,7 @@
|
|||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
params.set("flash", `error: ${json.error.message}`);
|
alert(`Failed to edit user: ${json.message}`);
|
||||||
} else {
|
|
||||||
params.set("flash", `success: User's ${field} updated.`);
|
|
||||||
}
|
}
|
||||||
url.search = params.toString();
|
url.search = params.toString();
|
||||||
window.location.assign(url);
|
window.location.assign(url);
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const UserSchema = z
|
|||||||
tokenCounts: tokenCountsSchema,
|
tokenCounts: tokenCountsSchema,
|
||||||
/** Maximum number of tokens the user can consume, by model family. */
|
/** Maximum number of tokens the user can consume, by model family. */
|
||||||
tokenLimits: tokenCountsSchema,
|
tokenLimits: tokenCountsSchema,
|
||||||
|
/** User-specific token refresh amount, by model family. */
|
||||||
|
tokenRefresh: tokenCountsSchema,
|
||||||
/** Time at which the user was created. */
|
/** Time at which the user was created. */
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
/** Time at which the user last connected. */
|
/** Time at which the user last connected. */
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function createUser(createOptions?: {
|
|||||||
type?: User["type"];
|
type?: User["type"];
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
tokenLimits?: User["tokenLimits"];
|
tokenLimits?: User["tokenLimits"];
|
||||||
|
tokenRefresh?: User["tokenRefresh"];
|
||||||
}) {
|
}) {
|
||||||
const token = uuid();
|
const token = uuid();
|
||||||
const newUser: User = {
|
const newUser: User = {
|
||||||
@@ -79,6 +80,7 @@ export function createUser(createOptions?: {
|
|||||||
promptCount: 0,
|
promptCount: 0,
|
||||||
tokenCounts: { ...INITIAL_TOKENS },
|
tokenCounts: { ...INITIAL_TOKENS },
|
||||||
tokenLimits: createOptions?.tokenLimits ?? { ...config.tokenQuota },
|
tokenLimits: createOptions?.tokenLimits ?? { ...config.tokenQuota },
|
||||||
|
tokenRefresh: createOptions?.tokenRefresh ?? { ...INITIAL_TOKENS },
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
meta: {},
|
meta: {},
|
||||||
};
|
};
|
||||||
@@ -123,6 +125,7 @@ export function upsertUser(user: UserUpdate) {
|
|||||||
promptCount: 0,
|
promptCount: 0,
|
||||||
tokenCounts: { ...INITIAL_TOKENS },
|
tokenCounts: { ...INITIAL_TOKENS },
|
||||||
tokenLimits: { ...config.tokenQuota },
|
tokenLimits: { ...config.tokenQuota },
|
||||||
|
tokenRefresh: { ...INITIAL_TOKENS },
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
meta: {},
|
meta: {},
|
||||||
};
|
};
|
||||||
@@ -139,7 +142,6 @@ export function upsertUser(user: UserUpdate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Write firebase migration to backfill new fields
|
|
||||||
if (updates.tokenCounts) {
|
if (updates.tokenCounts) {
|
||||||
for (const family of MODEL_FAMILIES) {
|
for (const family of MODEL_FAMILIES) {
|
||||||
updates.tokenCounts[family] ??= 0;
|
updates.tokenCounts[family] ??= 0;
|
||||||
@@ -150,6 +152,11 @@ export function upsertUser(user: UserUpdate) {
|
|||||||
updates.tokenLimits[family] ??= 0;
|
updates.tokenLimits[family] ??= 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (updates.tokenRefresh) {
|
||||||
|
for (const family of MODEL_FAMILIES) {
|
||||||
|
updates.tokenRefresh[family] ??= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
users.set(user.token, Object.assign(existing, updates));
|
users.set(user.token, Object.assign(existing, updates));
|
||||||
usersToFlush.add(user.token);
|
usersToFlush.add(user.token);
|
||||||
@@ -245,19 +252,30 @@ export function hasAvailableQuota({
|
|||||||
return tokensConsumed < tokenLimit;
|
return tokensConsumed < tokenLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the given user, sets token limits for each model family to the sum of the
|
||||||
|
* current count and the refresh amount, up to the default limit. If a quota is
|
||||||
|
* not specified for a model family, it is not touched.
|
||||||
|
*/
|
||||||
export function refreshQuota(token: string) {
|
export function refreshQuota(token: string) {
|
||||||
const user = users.get(token);
|
const user = users.get(token);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const { tokenCounts, tokenLimits } = user;
|
const { tokenQuota } = config;
|
||||||
const quotas = Object.entries(config.tokenQuota) as [ModelFamily, number][];
|
const { tokenCounts, tokenLimits, tokenRefresh } = user;
|
||||||
quotas
|
|
||||||
// If a quota is not configured, don't touch any existing limits a user may
|
// Get default quotas for each model family.
|
||||||
// already have been assigned manually.
|
const defaultQuotas = Object.entries(tokenQuota) as [ModelFamily, number][];
|
||||||
.filter(([, quota]) => quota > 0)
|
// If any user-specific refresh quotas are present, override default quotas.
|
||||||
.forEach(
|
const userQuotas = defaultQuotas.map(([f, q]) => [
|
||||||
([model, quota]) =>
|
f,
|
||||||
(tokenLimits[model] = (tokenCounts[model] ?? 0) + quota)
|
(tokenRefresh[f] ?? 0) || q,
|
||||||
);
|
] as const /* narrow to tuple */);
|
||||||
|
|
||||||
|
userQuotas
|
||||||
|
// Ignore families with no global or user-specific refresh quota.
|
||||||
|
.filter(([, q]) => q > 0)
|
||||||
|
// Increase family token limit by the family's refresh amount.
|
||||||
|
.forEach(([f, q]) => (tokenLimits[f] = (tokenCounts[f] ?? 0) + q));
|
||||||
usersToFlush.add(token);
|
usersToFlush.add(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +325,7 @@ function cleanupExpiredTokens() {
|
|||||||
user.meta.refreshable = config.captchaMode !== "none";
|
user.meta.refreshable = config.captchaMode !== "none";
|
||||||
disabled++;
|
disabled++;
|
||||||
}
|
}
|
||||||
const purgeTimeout = config.powTokenPurgeHours * 60 * 60 * 1000;
|
const purgeTimeout = config.powTokenPurgeHours * 60 * 60 * 1000;
|
||||||
if (user.disabledAt && user.disabledAt + purgeTimeout < now) {
|
if (user.disabledAt && user.disabledAt + purgeTimeout < now) {
|
||||||
users.delete(user.token);
|
users.delete(user.token);
|
||||||
usersToFlush.add(user.token);
|
usersToFlush.add(user.token);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
.pagination li a {
|
.pagination li a {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.5em 1em;
|
padding: 0.5em 1em;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.pagination li.active a {
|
.pagination li.active a {
|
||||||
@@ -71,20 +71,24 @@
|
|||||||
td.actions:hover {
|
td.actions:hover {
|
||||||
background-color: #e0e6f6;
|
background-color: #e0e6f6;
|
||||||
}
|
}
|
||||||
|
tr > td,
|
||||||
|
tr > th {
|
||||||
|
border-right: 1px solid #dedede;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
body {
|
body {
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.full-width {
|
table.full-width {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: static;
|
position: static;
|
||||||
left: auto;
|
left: auto;
|
||||||
right: auto;
|
right: auto;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -95,6 +99,13 @@
|
|||||||
th.active {
|
th.active {
|
||||||
background-color: #446;
|
background-color: #446;
|
||||||
}
|
}
|
||||||
|
td.actions:hover {
|
||||||
|
background-color: #446;
|
||||||
|
}
|
||||||
|
tr > td,
|
||||||
|
tr > th {
|
||||||
|
border-right: 1px solid #444;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<p>Next refresh: <time><%- nextQuotaRefresh %></time></p>
|
<p>
|
||||||
|
Next refresh: <time><%- nextQuotaRefresh %></time>
|
||||||
|
</p>
|
||||||
<table class="striped">
|
<table class="striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -9,7 +11,7 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
<th scope="col">Limit</th>
|
<th scope="col">Limit</th>
|
||||||
<th scope="col">Remaining</th>
|
<th scope="col">Remaining</th>
|
||||||
<th scope="col">Refresh Amount</th>
|
<th scope="col" colspan="<%= showRefreshEdit ? 2 : 1 %>">Refresh Amount</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -29,7 +31,20 @@
|
|||||||
<% if (user.type === "temporary") { %>
|
<% if (user.type === "temporary") { %>
|
||||||
<td>N/A</td>
|
<td>N/A</td>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<td><%- prettyTokens(quota[key]) %></td>
|
<td><%- prettyTokens(user.tokenRefresh[key] || quota[key]) %></td>
|
||||||
|
<% } %>
|
||||||
|
<% if (showRefreshEdit) { %>
|
||||||
|
<td class="actions">
|
||||||
|
<a
|
||||||
|
title="Edit"
|
||||||
|
id="edit-refresh"
|
||||||
|
href="#"
|
||||||
|
data-field="tokenRefresh_<%= key %>"
|
||||||
|
data-token="<%= user.token %>"
|
||||||
|
data-modelFamily="<%= key %>"
|
||||||
|
>✏️</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
<% } %>
|
<% } %>
|
||||||
</tr>
|
</tr>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>Quota Information</h3>
|
<h3>Quota Information</h3>
|
||||||
<%- include("partials/shared_quota-info", { quota, user }) %>
|
<%- include("partials/shared_quota-info", { quota, user, showRefreshEdit: false }) %>
|
||||||
|
|
||||||
<form id="edit-nickname-form" style="display: none" action="/user/edit-nickname" method="post">
|
<form id="edit-nickname-form" style="display: none" action="/user/edit-nickname" method="post">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||||
|
|||||||
Reference in New Issue
Block a user