Styling
This commit is contained in:
parent
6be67d71f8
commit
23bc808cca
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,39 +93,67 @@ 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}>
|
||||||
{loading && <p class={styles.loading}>Loading…</p>}
|
<span class={modalStyles.title}>Leaderboard</span>
|
||||||
{error && <p class={styles.error}>{error}</p>}
|
<button class={modalStyles.closeButton} onClick={onClose}><X size={16} /></button>
|
||||||
{!loading && !error && rows.length > 0 && (
|
</div>
|
||||||
<table class={styles.table}>
|
<div class={modalStyles.body}>
|
||||||
<thead>
|
{loading && <p class={styles.loading}>Loading…</p>}
|
||||||
<tr>
|
{error && <p class={styles.error}>{error}</p>}
|
||||||
<th>#</th>
|
{!loading && !error && rows.length > 0 && (
|
||||||
<th>Username</th>
|
<table class={styles.table}>
|
||||||
<th>Kudos</th>
|
<thead>
|
||||||
{user && <th>Diff</th>}
|
<tr>
|
||||||
</tr>
|
<th>#</th>
|
||||||
</thead>
|
<th>Username</th>
|
||||||
<tbody>
|
<th>Kudos</th>
|
||||||
{rows.map(r => (
|
{user && <th>Diff</th>}
|
||||||
<tr key={r.rank} class={r.username === user?.username ? styles.selfRow : undefined}>
|
|
||||||
<td>{r.rank}</td>
|
|
||||||
<td>{r.username}</td>
|
|
||||||
<td>{formatNumber(r.kudos)}</td>
|
|
||||||
{user && (
|
|
||||||
<td class={r.diff !== undefined ? (r.diff > 0 ? styles.above : styles.below) : undefined}>
|
|
||||||
{r.diff !== undefined ? `+${formatNumber(r.diff)}` : '—'}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{rows.map(r => (
|
||||||
)}
|
<tr key={r.rank} class={r.username === user?.username ? styles.selfRow : undefined}>
|
||||||
|
<td>{r.rank}</td>
|
||||||
|
<td>{r.username}</td>
|
||||||
|
<td>{formatNumber(r.kudos)}</td>
|
||||||
|
{user && (
|
||||||
|
<td class={r.diff !== undefined ? (r.diff > 0 ? styles.above : styles.below) : undefined}>
|
||||||
|
{r.diff !== undefined ? `+${formatNumber(r.diff)}` : '—'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
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>
|
||||||
</Modal>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,62 +87,57 @@ 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}>
|
||||||
{loading && <p class={styles.loading}>Loading…</p>}
|
<span class={modalStyles.title}>Manage Workers</span>
|
||||||
{!loading && workerDetails.map(w => {
|
<button class={modalStyles.closeButton} onClick={onClose}><X size={16} /></button>
|
||||||
const edit = edits[w.id] ?? { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode };
|
</div>
|
||||||
return (
|
<div class={modalStyles.body}>
|
||||||
<div key={w.id} class={styles.workerForm}>
|
{loading && <p class={styles.loading}>Loading…</p>}
|
||||||
<div class={styles.workerName}>{w.name}</div>
|
{!loading && workerDetails.map(w => {
|
||||||
<label class={styles.label}>
|
const edit = edits[w.id] ?? { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode };
|
||||||
<span>Name</span>
|
return (
|
||||||
<input
|
<div key={w.id} class={styles.workerForm}>
|
||||||
value={edit.name}
|
<div class={styles.workerName}>{w.name}</div>
|
||||||
onInput={e => setEdit(w.id, { name: (e.target as HTMLInputElement).value })}
|
<label class={styles.label}>
|
||||||
/>
|
<span>Name</span>
|
||||||
</label>
|
<input
|
||||||
<label class={styles.label}>
|
value={edit.name}
|
||||||
<span>Info</span>
|
onInput={e => setEdit(w.id, { name: (e.target as HTMLInputElement).value })}
|
||||||
<textarea
|
/>
|
||||||
class={styles.textarea}
|
</label>
|
||||||
value={edit.info}
|
<label class={styles.label}>
|
||||||
onInput={e => setEdit(w.id, { info: (e.target as HTMLTextAreaElement).value })}
|
<span>Info</span>
|
||||||
rows={3}
|
<textarea
|
||||||
/>
|
class={styles.textarea}
|
||||||
</label>
|
value={edit.info}
|
||||||
<label class={styles.checkboxLabel}>
|
onInput={e => setEdit(w.id, { info: (e.target as HTMLTextAreaElement).value })}
|
||||||
<input
|
rows={3}
|
||||||
type="checkbox"
|
/>
|
||||||
checked={edit.maintenance_mode}
|
</label>
|
||||||
onChange={e => setEdit(w.id, { maintenance_mode: (e.target as HTMLInputElement).checked })}
|
<label class={styles.checkboxLabel}>
|
||||||
/>
|
<input
|
||||||
<span>Maintenance mode</span>
|
type="checkbox"
|
||||||
</label>
|
checked={edit.maintenance_mode}
|
||||||
<div class={styles.formActions}>
|
onChange={e => setEdit(w.id, { maintenance_mode: (e.target as HTMLInputElement).checked })}
|
||||||
<button
|
/>
|
||||||
class={styles.saveBtn}
|
<span>Maintenance mode</span>
|
||||||
onClick={() => save(w.id)}
|
</label>
|
||||||
disabled={saving[w.id]}
|
<div class={styles.formActions}>
|
||||||
>
|
<button 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}
|
{deleting === w.id ? 'Deleting…' : 'Delete'}
|
||||||
onClick={() => confirmDelete(w.id)}
|
</button>
|
||||||
disabled={deleting === w.id}
|
</div>
|
||||||
>
|
|
||||||
{deleting === w.id ? 'Deleting…' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
<label class={styles.label}>
|
<span class={modalStyles.title}>Options</span>
|
||||||
<span>API Key</span>
|
<button class={modalStyles.closeButton} onClick={onClose}><X size={16} /></button>
|
||||||
<input
|
</div>
|
||||||
type="password"
|
<div class={modalStyles.body}>
|
||||||
class={styles.input}
|
<label class={styles.label}>
|
||||||
value={draft}
|
<span>API Key</span>
|
||||||
onInput={e => setDraft((e.target as HTMLInputElement).value)}
|
<input
|
||||||
placeholder="Enter your AI Horde API key"
|
type="password"
|
||||||
/>
|
value={draft}
|
||||||
</label>
|
onInput={e => setDraft((e.target as HTMLInputElement).value)}
|
||||||
<div class={styles.actions}>
|
onKeyDown={e => { if (e.key === 'Enter') save(); }}
|
||||||
|
placeholder="Enter your AI Horde API key"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</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(); }}>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue