feat(dashboard): send room messages from web (#33)
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: '使用量スナップショットなし。収集器を確認。',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user