1
0
Fork 0

Fix workers management modal

This commit is contained in:
Pabloader 2026-04-10 11:19:37 +00:00
parent 7f2987041e
commit b93424fece
5 changed files with 373 additions and 126 deletions

View File

@ -62,12 +62,11 @@
.buttonDanger {
composes: button;
background: var(--danger, #dc2626);
color: var(--bg);
background: var(--danger);
font-weight: 500;
&:hover {
background: var(--danger-alt, #b91c1c);
background: var(--danger-alt);
}
}

View File

@ -1,67 +1,181 @@
.modal {
max-width: 480px;
height: fit-content;
}
.loading {
color: var(--text-muted);
font-size: 14px;
text-align: center;
padding: 20px;
}
.workerForm {
.list {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-active);
border-radius: var(--radius);
padding: 12px;
gap: 12px;
}
.workerCard {
composes: card from '@common/assets/ui.module.css';
padding: 10px 14px;
gap: 8px;
border: 1px solid var(--border);
background: var(--bg-secondary);
}
.workerHeader {
display: flex;
align-items: center;
gap: 8px;
}
.workerInfo {
flex: 1;
min-width: 0;
}
.workerName {
font-weight: bold;
color: var(--accent-alt);
font-weight: 600;
font-size: 14px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.label {
display: flex;
flex-direction: column;
gap: 4px;
.workerModels {
font-size: 12px;
color: var(--text-muted);
input {
width: 100%;
}
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.textarea {
width: 100%;
resize: vertical;
.badges {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.badge {
composes: badge from '@common/assets/ui.module.css';
font-size: 11px;
padding: 2px 8px;
}
.badgeOnline {
composes: badge;
color: var(--accent-alt) !important;
font-weight: 500;
}
.badgeOffline {
composes: badge;
background: var(--bg-active);
color: var(--text-muted);
}
.badgeMaintenance {
composes: badge;
background: var(--warning, var(--bg-active));
color: var(--warning-text, var(--text));
}
.fields {
display: flex;
flex-direction: column;
gap: 6px;
.label {
margin-bottom: 2px;
font-size: 12px;
}
}
.checkboxLabel {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
font-size: 13px;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.formActions {
.actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.saveBtn {
composes: buttonSecondary from '@common/assets/ui.module.css';
.spacer {
flex: 1;
}
.deleteBtn {
composes: buttonDanger from '@common/assets/ui.module.css';
padding: 8px 16px;
border-radius: var(--radius);
font-size: 14px;
font-family: inherit;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
transition: all var(--transition);
&:hover:not(:disabled) {
background: var(--danger);
border-color: var(--danger);
color: var(--bg);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.deleteConfirm {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.empty {
composes: empty from '@common/assets/ui.module.css';
text-align: center;
padding: 32px 0;
}
.error {
composes: empty;
color: var(--danger);
}
.toastIcon {
display: inline;
vertical-align: middle;
margin-right: 4px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.footerInfo {
font-size: 13px;
color: var(--text-muted);
}
.footerActions {
display: flex;
gap: 8px;
}
.toast {
font-size: 13px;
color: var(--success, var(--accent-alt));
animation: fadeOut 1.5s ease forwards;
}
@keyframes fadeOut {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from "preact/hooks";
import { useBool } from "@common/hooks/useBool";
import { useHordeState } from "../../contexts/state";
import { fetchLeaderboard, type LeaderboardEntry } from "../../utils/api";
import { formatNumber, formatTime } from "@common/utils";
@ -23,7 +24,7 @@ export const LeaderboardModal = ({ open, onClose }: Props) => {
const kudosPerHour = totalUptime > 0 ? (totalGenerated / totalUptime) * 3600 : 0;
const [rows, setRows] = useState<(LeaderboardEntry & { rank: number; diff?: number })[]>([]);
const [loading, setLoading] = useState(false);
const loading = useBool(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@ -31,7 +32,7 @@ export const LeaderboardModal = ({ open, onClose }: Props) => {
let cancelled = false;
const load = async () => {
setLoading(true);
loading.setTrue();
setError(null);
try {
if (!user) {
@ -84,7 +85,7 @@ export const LeaderboardModal = ({ open, onClose }: Props) => {
} catch (e) {
if (!cancelled) setError(String(e));
} finally {
if (!cancelled) setLoading(false);
if (!cancelled) loading.setFalse();
}
};
@ -107,9 +108,9 @@ export const LeaderboardModal = ({ open, onClose }: Props) => {
return (
<Modal open={open} onClose={onClose} title="Leaderboard" class={styles.modal} footer={footer}>
{loading && <p class={styles.loading}>Loading</p>}
{loading.value && <p class={styles.loading}>Loading</p>}
{error && <p class={styles.error}>{error}</p>}
{!loading && !error && rows.length > 0 && (
{!loading.value && !error && rows.length > 0 && (
<table class={styles.table}>
<thead>
<tr>

View File

@ -1,8 +1,13 @@
import { useEffect, useState } from "preact/hooks";
import { useHordeState } from "../../contexts/state";
import { fetchWorker, updateWorker, deleteWorker, type WorkerData } from "../../utils/api";
import { Trash2, Check } from "lucide-preact";
import { useBool } from "@common/hooks/useBool";
import { useInputState } from "@common/hooks/useInputState";
import { Modal } from "@common/components/Modal";
import clsx from "clsx";
import styles from "../../assets/manage-workers-modal.module.css";
import ui from "@common/assets/ui.module.css";
import { useHordeState } from "../../contexts/state";
import { deleteWorker, fetchWorker, updateWorker, type WorkerData } from "../../utils/api";
interface Props {
open: boolean;
@ -15,111 +20,230 @@ interface WorkerEdit {
maintenance_mode: boolean;
}
interface WorkerCard {
data: WorkerData;
edit: WorkerEdit;
saving: boolean;
saved: boolean;
deleting: boolean;
confirmDelete: boolean;
}
export const ManageWorkersModal = ({ open, onClose }: Props) => {
const { state, dispatch } = useHordeState();
const { user, apiKey } = state;
const [workerDetails, setWorkerDetails] = useState<WorkerData[]>([]);
const [edits, setEdits] = useState<Record<string, WorkerEdit>>({});
const [saving, setSaving] = useState<Record<string, boolean>>({});
const [deleting, setDeleting] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [workers, setWorkers] = useState<WorkerCard[]>([]);
const [error, setError] = useState<string | null>(null);
const loading = useBool(false);
useEffect(() => {
if (!open || !user || !apiKey) return;
let cancelled = false;
setLoading(true);
loading.setTrue();
setError(null);
Promise.all(user.worker_ids.map(id => fetchWorker(id, apiKey)))
.then(details => {
if (cancelled) return;
setWorkerDetails(details);
const initialEdits: Record<string, WorkerEdit> = {};
for (const w of details) {
initialEdits[w.id] = { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode };
}
setEdits(initialEdits);
const sorted = details.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1;
return a.name.localeCompare(b.name);
});
setWorkers(sorted.map(w => ({
data: w,
edit: { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode },
saving: false,
saved: false,
deleting: false,
confirmDelete: false,
})));
})
.catch(console.error)
.finally(() => { if (!cancelled) setLoading(false); });
.catch(() => { if (!cancelled) setError('Failed to load workers'); })
.finally(() => { if (!cancelled) loading.setFalse(); });
return () => { cancelled = true; };
}, [open, user?.worker_ids.join(',')]);
const setEdit = (id: string, patch: Partial<WorkerEdit>) => {
setEdits(prev => ({ ...prev, [id]: { ...prev[id], ...patch } }));
setWorkers(prev => prev.map(w =>
w.data.id === id
? { ...w, edit: { ...w.edit, ...patch }, saved: false }
: w
));
};
const save = async (id: string) => {
if (!apiKey) return;
setSaving(prev => ({ ...prev, [id]: true }));
setWorkers(prev => prev.map(w => w.data.id === id ? { ...w, saving: true } : w));
try {
const updated = await updateWorker(id, apiKey, edits[id]);
setWorkerDetails(prev => prev.map(w => w.id === id ? updated : w));
} catch (e) {
console.error('[horde] save worker error:', e);
} finally {
setSaving(prev => ({ ...prev, [id]: false }));
const worker = workers.find(w => w.data.id === id)!;
const updated = await updateWorker(id, apiKey, worker.edit);
setWorkers(prev => prev.map(w =>
w.data.id === id
? { ...w, data: updated, saving: false, saved: true }
: w
));
setTimeout(() => {
setWorkers(prev => prev.map(w =>
w.data.id === id ? { ...w, saved: false } : w
));
}, 1500);
} catch {
setError('Failed to save worker');
setWorkers(prev => prev.map(w =>
w.data.id === id ? { ...w, saving: false } : w
));
}
};
const startDelete = (id: string) => {
setWorkers(prev => prev.map(w =>
w.data.id === id ? { ...w, confirmDelete: true } : w
));
};
const cancelDelete = (id: string) => {
setWorkers(prev => prev.map(w =>
w.data.id === id ? { ...w, confirmDelete: false } : w
));
};
const confirmDelete = async (id: string) => {
if (!apiKey || !confirm('Delete this worker? This cannot be undone.')) return;
setDeleting(id);
if (!apiKey) return;
setWorkers(prev => prev.map(w =>
w.data.id === id ? { ...w, deleting: true, confirmDelete: false } : w
));
try {
await deleteWorker(id, apiKey);
setWorkerDetails(prev => prev.filter(w => w.id !== id));
setWorkers(prev => prev.filter(w => w.data.id !== id));
dispatch({ type: 'SET_USER', user: { ...user!, worker_ids: user!.worker_ids.filter(i => i !== id) } });
} catch (e) {
console.error('[horde] delete worker error:', e);
} finally {
setDeleting(null);
} catch {
setError('Failed to delete worker');
setWorkers(prev => prev.map(w =>
w.data.id === id ? { ...w, deleting: false } : w
));
}
};
const hasChanges = workers.some(w =>
w.edit.name !== w.data.name ||
w.edit.info !== (w.data.info ?? '') ||
w.edit.maintenance_mode !== w.data.maintenance_mode
);
const footer = (
<div class={styles.footer}>
<span class={styles.footerInfo}>
{workers.length} worker{workers.length !== 1 ? 's' : ''}
{hasChanges && ' · unsaved changes'}
</span>
{hasChanges && (
<div class={styles.footerActions}>
<button class={ui.buttonPrimary} onClick={() => workers.forEach(w => { if (hasChangesForWorker(w)) save(w.data.id); })}>
Save All
</button>
</div>
)}
</div>
);
const hasChangesForWorker = (w: WorkerCard) =>
w.edit.name !== w.data.name ||
w.edit.info !== (w.data.info ?? '') ||
w.edit.maintenance_mode !== w.data.maintenance_mode;
return (
<Modal open={open && Boolean(user)} onClose={onClose} title="Manage Workers" class={styles.modal}>
{loading && <p class={styles.loading}>Loading</p>}
{!loading && user && workerDetails.map(w => {
const edit = edits[w.id] ?? { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode };
return (
<div key={w.id} class={styles.workerForm}>
<div class={styles.workerName}>{w.name}</div>
<label class={styles.label}>
<span>Name</span>
<Modal open={open && Boolean(user)} onClose={onClose} title="Manage Workers" class={styles.modal} footer={footer}>
{loading.value && <p class={styles.empty}>Loading workers</p>}
{error && <p class={styles.error}>{error}</p>}
{!loading.value && !error && workers.length === 0 && (
<p class={styles.empty}>No workers found.</p>
)}
{!loading.value && !error && workers.length > 0 && (
<div class={styles.list}>
{workers.map(w => (
<div key={w.data.id} class={styles.workerCard}>
<div class={styles.workerHeader}>
<div class={styles.workerInfo}>
<div class={styles.workerName}>{w.data.name}</div>
<div class={styles.workerModels}>
{w.data.models.slice(0, 3).join(', ')}
{w.data.models.length > 3 && ` +${w.data.models.length - 3}`}
</div>
</div>
<div class={styles.badges}>
<span class={clsx(styles.badge, w.data.online ? styles.badgeOnline : styles.badgeOffline)}>
{w.data.online ? 'online' : 'offline'}
</span>
{w.data.maintenance_mode && (
<span class={styles.badgeMaintenance}>maint</span>
)}
</div>
</div>
<div class={styles.fields}>
<div class={ui.formGroup}>
<label class={ui.label}>Name</label>
<input
value={edit.name}
onInput={e => setEdit(w.id, { name: (e.target as HTMLInputElement).value })}
class={ui.input}
value={w.edit.name}
onInput={e => setEdit(w.data.id, { name: (e.target as HTMLInputElement).value })}
/>
</label>
<label class={styles.label}>
<span>Info</span>
</div>
<div class={ui.formGroup}>
<label class={ui.label}>Info</label>
<textarea
class={styles.textarea}
value={edit.info}
onInput={e => setEdit(w.id, { info: (e.target as HTMLTextAreaElement).value })}
rows={3}
class={ui.textarea}
value={w.edit.info}
onInput={e => setEdit(w.data.id, { info: (e.target as HTMLTextAreaElement).value })}
rows={2}
/>
</label>
</div>
</div>
<div class={styles.actions}>
<label class={styles.checkboxLabel}>
<input
type="checkbox"
checked={edit.maintenance_mode}
onChange={e => setEdit(w.id, { maintenance_mode: (e.target as HTMLInputElement).checked })}
checked={w.edit.maintenance_mode}
onChange={e => setEdit(w.data.id, { maintenance_mode: (e.target as HTMLInputElement).checked })}
/>
<span>Maintenance mode</span>
Maint.
</label>
<div class={styles.formActions}>
<button class={styles.saveBtn} onClick={() => save(w.id)} disabled={saving[w.id]}>
{saving[w.id] ? 'Saving…' : 'Save'}
{w.saved && (
<span class={styles.toast}>
<Check size={14} class={styles.toastIcon} />
Saved
</span>
)}
<div class={styles.spacer} />
{w.confirmDelete ? (
<div class={styles.deleteConfirm}>
<span>Really delete?</span>
<button class={ui.confirmButton} onClick={() => confirmDelete(w.data.id)} disabled={w.deleting}>
Yes
</button>
<button class={styles.deleteBtn} onClick={() => confirmDelete(w.id)} disabled={deleting === w.id}>
{deleting === w.id ? 'Deleting…' : 'Delete'}
<button class={ui.cancelButton} onClick={() => cancelDelete(w.data.id)} disabled={w.deleting}>
No
</button>
</div>
) : (
<>
<button class={ui.buttonSecondary} onClick={() => save(w.data.id)} disabled={w.saving || !hasChangesForWorker(w)}>
{w.saving ? 'Saving…' : 'Save'}
</button>
<button class={styles.deleteBtn} onClick={() => startDelete(w.data.id)} disabled={w.deleting}>
<Trash2 size={16} />
Delete
</button>
</>
)}
</div>
);
})}
</div>
))}
</div>
)}
</Modal>
);
};

View File

@ -1,7 +1,8 @@
import { useBool } from "@common/hooks/useBool";
import { formatNumber, formatTime } from "@common/utils";
import type { WorkerData } from "../utils/api";
import clsx from "clsx";
import styles from "../assets/worker-card.module.css";
import type { WorkerData } from "../utils/api";
interface Props {
worker: WorkerData;
@ -12,10 +13,15 @@ export const WorkerCard = ({ worker, isOwn }: Props) => {
const expanded = useBool(false);
return (
<div class={`${styles.card} ${isOwn ? styles.own : ''} ${worker.online ? '' : styles.offline}`}>
<div class={clsx(styles.card, {
[styles.own]: isOwn,
[styles.offline]: !worker.online,
})}>
<div class={styles.header}>
<span
class={`${styles.name} ${worker.info ? styles.hasInfo : ''}`}
class={clsx(styles.name, {
[styles.hasInfo]: worker.info,
})}
onClick={worker.info ? expanded.toggle : undefined}
title={worker.info ? 'Click to expand' : undefined}
>
@ -23,11 +29,14 @@ export const WorkerCard = ({ worker, isOwn }: Props) => {
{worker.info ? <span class={styles.infoToggle}>{expanded.value ? ' ▲' : ' ▼'}</span> : null}
</span>
<div class={styles.badges}>
<span class={`${styles.badge} ${worker.online ? styles.online : styles.offlineBadge}`}>
<span class={clsx(styles.badge, {
[styles.online]: worker.online,
[styles.offlineBadge]: !worker.online,
})}>
{worker.online ? 'online' : 'offline'}
</span>
{worker.maintenance_mode && <span class={`${styles.badge} ${styles.maintenance}`}>maintenance</span>}
{worker.trusted && <span class={`${styles.badge} ${styles.trusted}`}>trusted</span>}
{worker.maintenance_mode && <span class={clsx(styles.badge, styles.maintenance)}>maintenance</span>}
{worker.trusted && <span class={clsx(styles.badge, styles.trusted)}>trusted</span>}
</div>
</div>