1
0
Fork 0
This commit is contained in:
Pabloader 2026-04-09 19:49:44 +00:00
parent 6be67d71f8
commit 23bc808cca
11 changed files with 303 additions and 188 deletions

View File

@ -1,14 +1,5 @@
.content { .modal {
display: flex; width: 480px;
flex-direction: column;
gap: 12px;
min-width: 380px;
max-width: 560px;
}
.title {
font-size: 18px;
color: var(--text);
} }
.loading, .error { .loading, .error {
@ -38,6 +29,10 @@
font-weight: normal; font-weight: normal;
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px;
position: sticky;
top: 0;
background: var(--bg);
} }
td { td {
@ -62,3 +57,18 @@
.below { .below {
color: var(--accent-alt) !important; color: var(--accent-alt) !important;
} }
.nextPlace {
font-size: 13px;
color: var(--text-muted);
strong {
color: var(--yellow);
}
}
.nextPlaceMeta {
font-size: 12px;
color: var(--text-muted);
opacity: 0.7;
}

View File

@ -1,16 +1,5 @@
.content { .modal {
display: flex; width: 480px;
flex-direction: column;
gap: 16px;
min-width: 360px;
max-width: 560px;
max-height: 80vh;
overflow-y: auto;
}
.title {
font-size: 18px;
color: var(--text);
} }
.loading { .loading {

View File

@ -0,0 +1,72 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
max-height: 80vh;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.title {
font-size: 16px;
font-weight: bold;
color: var(--text);
}
.closeButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: var(--radius);
cursor: pointer;
&:hover {
color: var(--text);
background: var(--bg-hover);
}
}
.body {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: row;
gap: 8px;
flex-shrink: 0;
align-items: center;
}

View File

@ -1,13 +1,5 @@
.content { .modal {
display: flex; width: 380px;
flex-direction: column;
gap: 16px;
min-width: 320px;
}
.title {
font-size: 18px;
color: var(--text);
} }
.label { .label {
@ -16,22 +8,12 @@
gap: 6px; gap: 6px;
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
}
.input { input {
width: 100%; width: 100%;
} }
.actions {
display: flex;
flex-direction: row;
gap: 8px;
} }
.saveBtn { .saveBtn {
color: var(--accent-alt) !important; color: var(--accent-alt) !important;
&:hover {
color: var(--accent-alt) !important;
}
} }

View File

@ -1,6 +1,6 @@
.panel { .panel {
width: 320px; width: 420px;
min-width: 260px; min-width: 320px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -73,11 +73,13 @@
.tableWrapper { .tableWrapper {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
flex: 1; flex: 1;
} }
.table { .table {
width: 100%; width: 100%;
table-layout: fixed;
border-collapse: collapse; border-collapse: collapse;
font-size: 12px; font-size: 12px;
@ -103,10 +105,7 @@
td { td {
color: var(--text-dim); color: var(--text-dim);
max-width: 140px; overflow-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
tr:hover td { tr:hover td {
@ -118,3 +117,9 @@
.ownModel td { .ownModel td {
color: var(--accent-alt) !important; color: var(--accent-alt) !important;
} }
.colNum {
width: 52px;
text-align: right;
white-space: nowrap;
}

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Modal } from "@common/components/modal/Modal"; import { X } from "lucide-preact";
import { useHordeState } from "../../contexts/state"; import { useHordeState } from "../../contexts/state";
import { fetchLeaderboard, type LeaderboardEntry } from "../../utils/api"; import { fetchLeaderboard, type LeaderboardEntry } from "../../utils/api";
import { formatNumber } from "@common/utils"; import { formatNumber, formatTime } from "@common/utils";
import modalStyles from "../../assets/modal.module.css";
import styles from "../../assets/leaderboard-modal.module.css"; import styles from "../../assets/leaderboard-modal.module.css";
interface Props { interface Props {
@ -14,7 +15,13 @@ const PAGE_SIZE = 25;
export const LeaderboardModal = ({ open, onClose }: Props) => { export const LeaderboardModal = ({ open, onClose }: Props) => {
const { state } = useHordeState(); const { state } = useHordeState();
const { user } = state; const { user, workers } = state;
const ownWorkerIds = new Set(user?.worker_ids ?? []);
const ownWorkers = workers.filter(w => ownWorkerIds.has(w.id));
const totalUptime = ownWorkers.reduce((s, w) => s + w.uptime, 0);
const totalGenerated = ownWorkers.reduce((s, w) => s + (w.kudos_details?.generated ?? 0), 0);
const kudosPerHour = totalUptime > 0 ? (totalGenerated / totalUptime) * 3600 : 0;
const [rows, setRows] = useState<(LeaderboardEntry & { rank: number; diff?: number })[]>([]); const [rows, setRows] = useState<(LeaderboardEntry & { rank: number; diff?: number })[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -29,13 +36,11 @@ export const LeaderboardModal = ({ open, onClose }: Props) => {
setError(null); setError(null);
try { try {
if (!user) { if (!user) {
// Show top 10
const page = await fetchLeaderboard(1); const page = await fetchLeaderboard(1);
if (!cancelled) { if (!cancelled) {
setRows(page.slice(0, 10).map((e, i) => ({ ...e, rank: i + 1 }))); setRows(page.slice(0, 10).map((e, i) => ({ ...e, rank: i + 1 })));
} }
} else { } else {
// Find user's rank via linear page scan, then show up to 10 above
let userRank = -1; let userRank = -1;
let page = 1; let page = 1;
outer: while (true) { outer: while (true) {
@ -48,12 +53,11 @@ export const LeaderboardModal = ({ open, onClose }: Props) => {
} }
} }
page++; page++;
if (page > 20) break; // safety cap if (page > 20) break;
} }
if (cancelled) return; if (cancelled) return;
// Collect rows: up to 10 above the user + user row
const startRank = Math.max(1, userRank - 10); const startRank = Math.max(1, userRank - 10);
const startPage = Math.ceil(startRank / PAGE_SIZE); const startPage = Math.ceil(startRank / PAGE_SIZE);
const endPage = Math.ceil(userRank / PAGE_SIZE); const endPage = Math.ceil(userRank / PAGE_SIZE);
@ -89,10 +93,23 @@ export const LeaderboardModal = ({ open, onClose }: Props) => {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [open, user?.username]); }, [open, user?.username]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open]);
if (!open) return null;
return ( return (
<Modal open={open} onClose={onClose}> <div class={modalStyles.overlay} onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div class={styles.content}> <div class={`${modalStyles.modal} ${styles.modal}`}>
<h2 class={styles.title}>Leaderboard</h2> <div class={modalStyles.header}>
<span class={modalStyles.title}>Leaderboard</span>
<button class={modalStyles.closeButton} onClick={onClose}><X size={16} /></button>
</div>
<div class={modalStyles.body}>
{loading && <p class={styles.loading}>Loading</p>} {loading && <p class={styles.loading}>Loading</p>}
{error && <p class={styles.error}>{error}</p>} {error && <p class={styles.error}>{error}</p>}
{!loading && !error && rows.length > 0 && ( {!loading && !error && rows.length > 0 && (
@ -122,6 +139,21 @@ export const LeaderboardModal = ({ open, onClose }: Props) => {
</table> </table>
)} )}
</div> </div>
</Modal> {(() => {
if (!user || rows.length < 2 || kudosPerHour <= 0) return null;
const nextRow = rows[rows.length - 2];
if (!nextRow.diff || nextRow.diff <= 0) return null;
const secs = Math.ceil((nextRow.diff / kudosPerHour) * 3600);
return (
<div class={modalStyles.footer}>
<span class={styles.nextPlace}>
Time to #{nextRow.rank}: <strong>{formatTime(secs)}</strong>
<span class={styles.nextPlaceMeta}> ({formatNumber(Math.round(kudosPerHour))} kudos/h)</span>
</span>
</div>
);
})()}
</div>
</div>
); );
}; };

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Modal } from "@common/components/modal/Modal"; import { X } from "lucide-preact";
import { useHordeState } from "../../contexts/state"; import { useHordeState } from "../../contexts/state";
import { fetchWorker, updateWorker, deleteWorker, type WorkerData } from "../../utils/api"; import { fetchWorker, updateWorker, deleteWorker, type WorkerData } from "../../utils/api";
import modalStyles from "../../assets/modal.module.css";
import styles from "../../assets/manage-workers-modal.module.css"; import styles from "../../assets/manage-workers-modal.module.css";
interface Props { interface Props {
@ -36,11 +37,7 @@ export const ManageWorkersModal = ({ open, onClose }: Props) => {
setWorkerDetails(details); setWorkerDetails(details);
const initialEdits: Record<string, WorkerEdit> = {}; const initialEdits: Record<string, WorkerEdit> = {};
for (const w of details) { for (const w of details) {
initialEdits[w.id] = { initialEdits[w.id] = { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode };
name: w.name,
info: w.info ?? '',
maintenance_mode: w.maintenance_mode,
};
} }
setEdits(initialEdits); setEdits(initialEdits);
}) })
@ -50,6 +47,15 @@ export const ManageWorkersModal = ({ open, onClose }: Props) => {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [open, user?.worker_ids.join(',')]); }, [open, user?.worker_ids.join(',')]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open]);
if (!open || !user) return null;
const setEdit = (id: string, patch: Partial<WorkerEdit>) => { const setEdit = (id: string, patch: Partial<WorkerEdit>) => {
setEdits(prev => ({ ...prev, [id]: { ...prev[id], ...patch } })); setEdits(prev => ({ ...prev, [id]: { ...prev[id], ...patch } }));
}; };
@ -68,14 +74,12 @@ export const ManageWorkersModal = ({ open, onClose }: Props) => {
}; };
const confirmDelete = async (id: string) => { const confirmDelete = async (id: string) => {
if (!apiKey) return; if (!apiKey || !confirm('Delete this worker? This cannot be undone.')) return;
if (!confirm('Delete this worker? This cannot be undone.')) return;
setDeleting(id); setDeleting(id);
try { try {
await deleteWorker(id, apiKey); await deleteWorker(id, apiKey);
setWorkerDetails(prev => prev.filter(w => w.id !== id)); setWorkerDetails(prev => prev.filter(w => w.id !== id));
// Trigger a re-fetch of the user profile on next poll dispatch({ type: 'SET_USER', user: { ...user, worker_ids: user.worker_ids.filter(i => i !== id) } });
dispatch({ type: 'SET_USER', user: user ? { ...user, worker_ids: user.worker_ids.filter(i => i !== id) } : null });
} catch (e) { } catch (e) {
console.error('[horde] delete worker error:', e); console.error('[horde] delete worker error:', e);
} finally { } finally {
@ -83,12 +87,14 @@ export const ManageWorkersModal = ({ open, onClose }: Props) => {
} }
}; };
if (!user) return null;
return ( return (
<Modal open={open} onClose={onClose}> <div class={modalStyles.overlay} onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div class={styles.content}> <div class={`${modalStyles.modal} ${styles.modal}`}>
<h2 class={styles.title}>Manage Workers</h2> <div class={modalStyles.header}>
<span class={modalStyles.title}>Manage Workers</span>
<button class={modalStyles.closeButton} onClick={onClose}><X size={16} /></button>
</div>
<div class={modalStyles.body}>
{loading && <p class={styles.loading}>Loading</p>} {loading && <p class={styles.loading}>Loading</p>}
{!loading && workerDetails.map(w => { {!loading && workerDetails.map(w => {
const edit = edits[w.id] ?? { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode }; const edit = edits[w.id] ?? { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode };
@ -120,18 +126,10 @@ export const ManageWorkersModal = ({ open, onClose }: Props) => {
<span>Maintenance mode</span> <span>Maintenance mode</span>
</label> </label>
<div class={styles.formActions}> <div class={styles.formActions}>
<button <button class={styles.saveBtn} onClick={() => save(w.id)} disabled={saving[w.id]}>
class={styles.saveBtn}
onClick={() => save(w.id)}
disabled={saving[w.id]}
>
{saving[w.id] ? 'Saving…' : 'Save'} {saving[w.id] ? 'Saving…' : 'Save'}
</button> </button>
<button <button class={styles.deleteBtn} onClick={() => confirmDelete(w.id)} disabled={deleting === w.id}>
class={styles.deleteBtn}
onClick={() => confirmDelete(w.id)}
disabled={deleting === w.id}
>
{deleting === w.id ? 'Deleting…' : 'Delete'} {deleting === w.id ? 'Deleting…' : 'Delete'}
</button> </button>
</div> </div>
@ -139,6 +137,7 @@ export const ManageWorkersModal = ({ open, onClose }: Props) => {
); );
})} })}
</div> </div>
</Modal> </div>
</div>
); );
}; };

View File

@ -1,6 +1,7 @@
import { useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Modal } from "@common/components/modal/Modal"; import { X } from "lucide-preact";
import { useHordeState } from "../../contexts/state"; import { useHordeState } from "../../contexts/state";
import modalStyles from "../../assets/modal.module.css";
import styles from "../../assets/options-modal.module.css"; import styles from "../../assets/options-modal.module.css";
interface Props { interface Props {
@ -12,34 +13,55 @@ export const OptionsModal = ({ open, onClose }: Props) => {
const { state, dispatch } = useHordeState(); const { state, dispatch } = useHordeState();
const [draft, setDraft] = useState(state.apiKey); const [draft, setDraft] = useState(state.apiKey);
useEffect(() => {
if (open) setDraft(state.apiKey);
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open]);
if (!open) return null;
const save = () => { const save = () => {
dispatch({ type: 'SET_API_KEY', apiKey: draft.trim() }); dispatch({ type: 'SET_API_KEY', apiKey: draft.trim() });
onClose(); onClose();
}; };
const clear = () => {
dispatch({ type: 'SET_API_KEY', apiKey: '' });
setDraft('');
onClose();
};
return ( return (
<Modal open={open} onClose={onClose}> <div class={modalStyles.overlay} onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div class={styles.content}> <div class={`${modalStyles.modal} ${styles.modal}`}>
<h2 class={styles.title}>Options</h2> <div class={modalStyles.header}>
<span class={modalStyles.title}>Options</span>
<button class={modalStyles.closeButton} onClick={onClose}><X size={16} /></button>
</div>
<div class={modalStyles.body}>
<label class={styles.label}> <label class={styles.label}>
<span>API Key</span> <span>API Key</span>
<input <input
type="password" type="password"
class={styles.input}
value={draft} value={draft}
onInput={e => setDraft((e.target as HTMLInputElement).value)} onInput={e => setDraft((e.target as HTMLInputElement).value)}
onKeyDown={e => { if (e.key === 'Enter') save(); }}
placeholder="Enter your AI Horde API key" placeholder="Enter your AI Horde API key"
autoFocus
/> />
</label> </label>
<div class={styles.actions}> </div>
<div class={modalStyles.footer}>
<button class={styles.saveBtn} onClick={save}>Save</button> <button class={styles.saveBtn} onClick={save}>Save</button>
{state.apiKey && ( {state.apiKey && <button onClick={clear}>Clear</button>}
<button onClick={() => { dispatch({ type: 'SET_API_KEY', apiKey: '' }); setDraft(''); onClose(); }}> </div>
Clear
</button>
)}
</div> </div>
</div> </div>
</Modal>
); );
}; };

View File

@ -3,14 +3,14 @@ import { useHordeState } from "../contexts/state";
import { formatNumber, formatTime } from "@common/utils"; import { formatNumber, formatTime } from "@common/utils";
import styles from "../assets/stats-panel.module.css"; import styles from "../assets/stats-panel.module.css";
type ModelSortKey = 'name' | 'count' | 'queued' | 'performance'; type ModelSortKey = 'name' | 'count' | 'queued' | 'jobs' | 'performance';
type SortDir = 'asc' | 'desc'; type SortDir = 'asc' | 'desc';
export const StatsPanel = () => { export const StatsPanel = () => {
const { state } = useHordeState(); const { state } = useHordeState();
const { user, workers, performance, models } = state; const { user, workers, performance, models } = state;
const [sortKey, setSortKey] = useState<ModelSortKey>('count'); const [sortKey, setSortKey] = useState<ModelSortKey>('jobs');
const [sortDir, setSortDir] = useState<SortDir>('desc'); const [sortDir, setSortDir] = useState<SortDir>('desc');
const ownWorkerIds = new Set(user?.worker_ids ?? []); const ownWorkerIds = new Set(user?.worker_ids ?? []);
@ -30,6 +30,7 @@ export const StatsPanel = () => {
if (sortKey === 'name') cmp = a.name.localeCompare(b.name); if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
else if (sortKey === 'count') cmp = a.count - b.count; else if (sortKey === 'count') cmp = a.count - b.count;
else if (sortKey === 'queued') cmp = a.queued - b.queued; else if (sortKey === 'queued') cmp = a.queued - b.queued;
else if (sortKey === 'jobs') cmp = a.jobs - b.jobs;
else cmp = a.performance - b.performance; else cmp = a.performance - b.performance;
return sortDir === 'asc' ? cmp : -cmp; return sortDir === 'asc' ? cmp : -cmp;
}); });
@ -105,18 +106,20 @@ export const StatsPanel = () => {
<thead> <thead>
<tr> <tr>
<th onClick={() => handleSort('name')}>Name{sortIndicator('name')}</th> <th onClick={() => handleSort('name')}>Name{sortIndicator('name')}</th>
<th onClick={() => handleSort('count')}>Workers{sortIndicator('count')}</th> <th class={styles.colNum} onClick={() => handleSort('count')}>Workers{sortIndicator('count')}</th>
<th onClick={() => handleSort('queued')}>Queued{sortIndicator('queued')}</th> <th class={styles.colNum} onClick={() => handleSort('jobs')}>Jobs{sortIndicator('jobs')}</th>
<th onClick={() => handleSort('performance')}>Perf{sortIndicator('performance')}</th> <th class={styles.colNum} onClick={() => handleSort('queued')}>Queued{sortIndicator('queued')}</th>
<th class={styles.colNum} onClick={() => handleSort('performance')}>Perf{sortIndicator('performance')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedModels.map(m => ( {sortedModels.map(m => (
<tr key={m.name} class={ownModelNames.has(m.name) ? styles.ownModel : undefined}> <tr key={m.name} class={ownModelNames.has(m.name) ? styles.ownModel : undefined}>
<td title={m.name}>{m.name}</td> <td>{m.name}</td>
<td>{m.count}</td> <td class={styles.colNum}>{m.count}</td>
<td>{formatNumber(m.queued)}</td> <td class={styles.colNum}>{m.jobs}</td>
<td>{m.performance > 0 ? `${m.performance.toFixed(1)}` : '—'}</td> <td class={styles.colNum}>{formatNumber(m.queued)}</td>
<td class={styles.colNum}>{m.performance > 0 ? `${m.performance.toFixed(1)}` : '—'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@ -35,8 +35,8 @@ export const WorkersPanel = () => {
const { state } = useHordeState(); const { state } = useHordeState();
const { workers, user } = state; const { workers, user } = state;
const [sortKey, setSortKey] = useState<WorkerSortKey>('uptime'); const [sortKey, setSortKey] = useState<WorkerSortKey>('name');
const [sortDir, setSortDir] = useState<SortDir>('desc'); const [sortDir, setSortDir] = useState<SortDir>('asc');
const ownWorkerIds = new Set(user?.worker_ids ?? []); const ownWorkerIds = new Set(user?.worker_ids ?? []);
const sorted = sortWorkers(workers, sortKey, sortDir); const sorted = sortWorkers(workers, sortKey, sortDir);

View File

@ -43,6 +43,7 @@ export interface ModelData {
name: string; name: string;
count: number; count: number;
queued: number; queued: number;
jobs: number;
performance: number; performance: number;
eta: number; eta: number;
type: string; type: string;