feat(dashboard): send room messages from web (#33)

This commit is contained in:
Eyejoker
2026-04-27 02:23:32 +09:00
committed by GitHub
parent a352b9af5b
commit 1676f53f4d
10 changed files with 612 additions and 6 deletions

View File

@@ -7,6 +7,7 @@ import {
type StatusSnapshot,
fetchDashboardData,
runScheduledTaskAction,
sendRoomMessage,
} from './api';
import {
LOCALES,
@@ -44,6 +45,10 @@ const DEFAULT_VIEW: DashboardView = 'inbox';
const HEALTH_STALE_MS = 5 * 60_000;
const HEALTH_DOWN_MS = 15 * 60_000;
function makeClientRequestId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function isDashboardView(
value: string | null | undefined,
): value is DashboardView {
@@ -865,13 +870,58 @@ function HealthPanel({
);
}
function RoomMessageForm({
busy,
onChange,
onSubmit,
t,
value,
}: {
busy: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
t: Messages;
value: string;
}) {
return (
<form
className="room-compose"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<textarea
aria-label={t.rooms.message}
maxLength={8000}
onChange={(event) => onChange(event.target.value)}
placeholder={t.rooms.messagePlaceholder}
rows={2}
value={value}
/>
<button disabled={busy || !value.trim()} type="submit">
{busy ? t.rooms.sending : t.rooms.send}
</button>
</form>
);
}
function RoomPanel({
onSendRoomMessage,
roomMessageKey,
snapshots,
t,
}: {
onSendRoomMessage: (
roomJid: string,
text: string,
requestId: string,
) => Promise<boolean>;
roomMessageKey: string | null;
snapshots: StatusSnapshot[];
t: Messages;
}) {
const [drafts, setDrafts] = useState<Record<string, string>>({});
const entries = snapshots.flatMap((snapshot) =>
snapshot.entries.map((entry) => ({
...entry,
@@ -883,6 +933,19 @@ function RoomPanel({
return <EmptyState>{t.rooms.empty}</EmptyState>;
}
function setDraft(jid: string, value: string) {
setDrafts((previous) => ({ ...previous, [jid]: value }));
}
async function submitRoomMessage(jid: string) {
const text = drafts[jid]?.trim();
if (!text) return;
const success = await onSendRoomMessage(jid, text, makeClientRequestId());
if (success) {
setDraft(jid, '');
}
}
return (
<>
<div className="table-wrap desktop-table">
@@ -893,6 +956,7 @@ function RoomPanel({
<th>{t.rooms.status}</th>
<th>{t.rooms.queue}</th>
<th>{t.rooms.elapsed}</th>
<th>{t.rooms.message}</th>
</tr>
</thead>
<tbody>
@@ -917,6 +981,15 @@ function RoomPanel({
{queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
</td>
<td>{formatDuration(entry.elapsedMs, t)}</td>
<td>
<RoomMessageForm
busy={roomMessageKey === entry.jid}
onChange={(value) => setDraft(entry.jid, value)}
onSubmit={() => void submitRoomMessage(entry.jid)}
t={t}
value={drafts[entry.jid] ?? ''}
/>
</td>
</tr>
))}
</tbody>
@@ -950,6 +1023,13 @@ function RoomPanel({
<strong>{formatDuration(entry.elapsedMs, t)}</strong>
</span>
</div>
<RoomMessageForm
busy={roomMessageKey === entry.jid}
onChange={(value) => setDraft(entry.jid, value)}
onSubmit={() => void submitRoomMessage(entry.jid)}
t={t}
value={drafts[entry.jid] ?? ''}
/>
<details className="record-details">
<summary>{t.rooms.details}</summary>
<p className="record-id">
@@ -1349,6 +1429,7 @@ function App() {
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
null,
);
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
const t = messages[locale];
function setDashboardLocale(nextLocale: Locale) {
@@ -1397,6 +1478,24 @@ function App() {
}
}
async function handleRoomMessage(
roomJid: string,
text: string,
requestId: string,
) {
setRoomMessageKey(roomJid);
try {
await sendRoomMessage(roomJid, text, requestId);
await refresh(false);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setRoomMessageKey(null);
}
}
useEffect(() => {
document.documentElement.lang = localeTags[locale];
}, [locale]);
@@ -1520,7 +1619,12 @@ function App() {
<h2>{t.panels.rooms}</h2>
<span>{t.panels.queue}</span>
</div>
<RoomPanel snapshots={data.snapshots} t={t} />
<RoomPanel
onSendRoomMessage={handleRoomMessage}
roomMessageKey={roomMessageKey}
snapshots={data.snapshots}
t={t}
/>
</section>
) : null}

View File

@@ -161,3 +161,14 @@ export async function runScheduledTaskAction(
action,
});
}
export async function sendRoomMessage(
roomJid: string,
text: string,
requestId: string,
): Promise<{ ok: true; id: string; queued: boolean }> {
return postJson(`/api/rooms/${encodeURIComponent(roomJid)}/messages`, {
requestId,
text,
});
}

View File

@@ -115,6 +115,10 @@ export interface Messages {
queue: string;
elapsed: string;
details: string;
message: string;
messagePlaceholder: string;
send: string;
sending: string;
};
usage: {
empty: string;
@@ -331,6 +335,10 @@ export const messages = {
queue: '큐',
elapsed: '경과',
details: '세부',
message: 'Message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
},
usage: {
empty: '사용량 스냅샷 없음. 수집기 확인.',
@@ -531,6 +539,10 @@ export const messages = {
queue: 'queue',
elapsed: 'elapsed',
details: 'details',
message: 'message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
},
usage: {
empty: 'No usage snapshot. Check collector.',
@@ -731,6 +743,10 @@ export const messages = {
queue: '队列',
elapsed: '耗时',
details: '详情',
message: 'Message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
},
usage: {
empty: '暂无用量快照。检查采集器。',
@@ -931,6 +947,10 @@ export const messages = {
queue: 'キュー',
elapsed: '経過',
details: '詳細',
message: 'Message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
},
usage: {
empty: '使用量スナップショットなし。収集器を確認。',

View File

@@ -513,7 +513,7 @@ dd,
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
min-width: 860px;
}
th,
@@ -1095,6 +1095,44 @@ progress::-moz-progress-bar {
display: none;
}
.room-compose {
display: grid;
min-width: min(240px, 100%);
gap: 8px;
}
.room-compose textarea {
width: 100%;
min-height: 54px;
resize: vertical;
border: 1px solid rgba(43, 55, 38, 0.14);
border-radius: 14px;
padding: 10px 11px;
color: var(--ink);
background: rgba(255, 250, 240, 0.68);
font: inherit;
line-height: 1.3;
}
.room-compose textarea:focus-visible {
outline: 3px solid rgba(191, 95, 44, 0.22);
}
.room-compose button {
min-height: 38px;
border: 0;
border-radius: 999px;
color: #fffaf0;
background: var(--ink);
font-weight: 900;
cursor: pointer;
}
.room-compose button:disabled {
cursor: not-allowed;
opacity: 0.44;
}
.task-board {
display: grid;
gap: 12px;