"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import { openDatePicker } from "@/components/ui/date-picker";
import { FilterButton } from "@/components/ui/FilterButton";
import { Pagination } from "@/components/ui/Pagination";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import {
DS_LOG_DEFAULT_PER_PAGE,
DS_LOG_PER_PAGE_OPTIONS,
EXPORT_MAX_BYTES,
EXPORT_MAX_FILES,
} from "@/lib/config/ds-agent";
import {
apiErrorMessage,
detailStrings,
formatDateTime,
formatFileSize,
} from "@/lib/ds-logs/modal-text";
import {
dateRangeError,
periodFromDates,
periodFromPreset,
type DsLogPeriodPreset,
} from "@/lib/ds-logs/presets";
import { deviceLogTypesParam } from "@/lib/ds-logs/query";
import { THEME_TOKENS } from "@/lib/theme";
import type {
DsAgentTokenIssued,
DsAgentTokenStatus,
DsLogExportResponse,
DsLogFile,
DsLogListResponse,
DsLogSort,
DsLogType,
} from "@/lib/types";
import { shouldCloseOnBackdrop, trapFocusIndex } from "@/lib/ui/modal";
import styles from "./DeviceLogsModal.module.css";
interface DeviceLogsModalProps {
serialNumber: string;
facilityName: string;
/** 閉じたときにフォーカスを戻す「ログ」ボタン */
restoreFocusRef: RefObject<HTMLButtonElement | null>;
onClose: () => void;
}
/** Tab の閉じ込めに使う、窓の中のフォーカスできる要素(ErrorContentPicker と同じ) */
const FOCUSABLE_SELECTOR = [
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"a[href]",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
type PeriodMode =
| { kind: "preset"; preset: DsLogPeriodPreset }
| { kind: "dates"; start: string; end: string };
/**
* 制御盤のログのモーダル(指示書AY 段階9-2。D-179)。
* 見た目は AreaModal、フォーカス・Tab・背景クリックは ErrorContentPicker に揃える。
*/
export function DeviceLogsModal({
serialNumber,
facilityName,
restoreFocusRef,
onClose,
}: DeviceLogsModalProps) {
const { t, theme } = useTheme();
const c = THEME_TOKENS[theme];
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const initialFocusRef = useRef<HTMLButtonElement>(null);
const downOnOverlayRef = useRef(false);
const exportAbortRef = useRef<AbortController | null>(null);
const [period, setPeriod] = useState<PeriodMode>({ kind: "preset", preset: "24h" });
const [sort, setSort] = useState<DsLogSort>("date_desc");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(DS_LOG_DEFAULT_PER_PAGE);
/** null = まだ種類が来ていない。空 Set = 1つも選んでいない */
const [selectedTypes, setSelectedTypes] = useState<Set<string> | null>(null);
const [types, setTypes] = useState<DsLogType[]>([]);
const [items, setItems] = useState<DsLogFile[]>([]);
const [total, setTotal] = useState(0);
const [truncated, setTruncated] = useState(false);
const [connected, setConnected] = useState(false);
const [lastSeenAt, setLastSeenAt] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [listError, setListError] = useState<string | null>(null);
/** file_id → size_bytes。ページ送り・並べ替えでは保持、種類・期間変更で消す(D23) */
const [selection, setSelection] = useState<Map<string, number>>(() => new Map());
const [exporting, setExporting] = useState(false);
const [exportMessage, setExportMessage] = useState<string | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [tokenIssuedAt, setTokenIssuedAt] = useState<string | null>(null);
const [tokenValue, setTokenValue] = useState<string | null>(null);
const [tokenBusy, setTokenBusy] = useState(false);
const [tokenError, setTokenError] = useState<string | null>(null);
const [tokenLoading, setTokenLoading] = useState(true);
const typeLabels = useMemo(() => {
const map = new Map<string, string>();
for (const type of types) map.set(type.key, type.label);
return map;
}, [types]);
const periodRange = useMemo(() => {
if (period.kind === "preset") return periodFromPreset(period.preset, new Date());
return periodFromDates(
period.start === "" ? null : period.start,
period.end === "" ? null : period.end
);
}, [period]);
const rangeError = useMemo(() => {
if (period.kind !== "dates") return null;
return dateRangeError(
period.start === "" ? null : period.start,
period.end === "" ? null : period.end
);
}, [period]);
/** 制御盤に種類があるときだけ「選んでください」(指示書BJ J10) */
const noTypeSelected =
selectedTypes !== null && selectedTypes.size === 0 && types.length > 0;
/** null = 取得しない(種類0件・日付不正)。全種類/未初期化は types パラメータ無し(指示書BJ J10) */
const queryString = useMemo(() => {
if (noTypeSelected || periodRange === null) return null;
const params = new URLSearchParams();
const typesParam = deviceLogTypesParam(
types.map((x) => x.key),
selectedTypes
);
if (typesParam !== null) params.set("types", typesParam);
if (periodRange.from) params.set("from", periodRange.from);
if (periodRange.to) params.set("to", periodRange.to);
params.set("sort", sort);
params.set("page", String(page));
params.set("per_page", String(perPage));
return params.toString();
}, [noTypeSelected, selectedTypes, types, periodRange, sort, page, perPage]);
// Esc・フォーカス復帰・背景スクロール停止
useEffect(() => {
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
initialFocusRef.current?.focus();
const opener = restoreFocusRef.current;
return () => {
document.body.style.overflow = prevOverflow;
opener?.focus();
exportAbortRef.current?.abort();
};
}, [restoreFocusRef]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
const focusables = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
const active = document.activeElement;
const activeIndex = active instanceof HTMLElement ? focusables.indexOf(active) : -1;
const next = trapFocusIndex(focusables.length, activeIndex, e.shiftKey);
if (focusables.length === 0) {
e.preventDefault();
return;
}
if (next !== null) {
e.preventDefault();
focusables[next]?.focus();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
// 制御盤トークンの発行日時(未接続でも取得する)
useEffect(() => {
let cancelled = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- モーダル表示時のトークン状態の取り直し
setTokenLoading(true);
setTokenError(null);
apiFetch<DsAgentTokenStatus>(
`/api/ds-devices/${encodeURIComponent(serialNumber)}/agent-token`
)
.then(
(body) => {
if (cancelled) return;
setTokenIssuedAt(body.issued_at);
},
(e: unknown) => {
if (cancelled) return;
setTokenError(apiErrorMessage(e, t) ?? t("データの取得に失敗しました"));
}
)
.finally(() => {
if (!cancelled) setTokenLoading(false);
});
return () => {
cancelled = true;
};
}, [serialNumber, t]);
// 一覧の取得(BC 段階1 の作法。古い応答は cancelled で捨てる)
useEffect(() => {
if (queryString === null) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- 種類0件・日付不正では取得せず文言だけ出す
setLoading(false);
setListError(
noTypeSelected
? t("種類を1つ以上選んでください")
: rangeError === "reversed"
? t("開始日は終了日以前にしてください")
: t("日付の範囲が不正です")
);
setItems([]);
setTotal(0);
setTruncated(false);
return;
}
let cancelled = false;
setLoading(true);
setListError(null);
apiFetch<DsLogListResponse>(
`/api/ds-devices/${encodeURIComponent(serialNumber)}/logs?${queryString}`
)
.then(
(body) => {
if (cancelled) return;
setTypes(body.types);
setSelectedTypes((prev) => {
if (prev === null) return new Set(body.types.map((x) => x.key));
return prev;
});
setItems(body.items);
setTotal(body.meta.total);
setTruncated(body.truncated);
setConnected(body.agent.connected);
setLastSeenAt(body.agent.last_seen_at);
const pages = Math.max(1, Math.ceil(body.meta.total / body.meta.per_page) || 1);
if (body.meta.page > pages) setPage(pages);
},
(e: unknown) => {
if (cancelled) return;
const message = apiErrorMessage(e, t);
if (message === null) return;
setListError(message);
setItems([]);
setTotal(0);
setTruncated(false);
if (e instanceof ApiFetchError && e.code === "agent_offline") {
setConnected(false);
const last = detailStrings(e.details, "last_seen_at")[0] ?? null;
setLastSeenAt(last);
} else if (e instanceof ApiFetchError && e.code === "agent_error") {
// ★制御盤は結果を返しているので接続中(指示書BJ J2)★
setConnected(true);
}
}
)
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [serialNumber, queryString, noTypeSelected, rangeError, t]);
const selectedCount = selection.size;
const selectedBytes = useMemo(() => {
let sum = 0;
for (const size of selection.values()) sum += size;
return sum;
}, [selection]);
const overMaxBytes = selectedBytes > EXPORT_MAX_BYTES;
const overMaxFiles = selectedCount > EXPORT_MAX_FILES;
const canExport =
selectedCount > 0 && !overMaxBytes && !overMaxFiles && !exporting && !loading;
const pageAllSelected =
items.length > 0 && items.every((item) => selection.has(item.file_id));
const clearSelection = () => setSelection(new Map());
const changePeriodPreset = (preset: DsLogPeriodPreset) => {
setPeriod({ kind: "preset", preset });
setPage(1);
clearSelection();
};
const changeDate = (which: "start" | "end", value: string) => {
setPeriod((prev) => {
const start = which === "start" ? value : prev.kind === "dates" ? prev.start : "";
const end = which === "end" ? value : prev.kind === "dates" ? prev.end : "";
return { kind: "dates", start, end };
});
setPage(1);
clearSelection();
};
const toggleType = (key: string) => {
setSelectedTypes((prev) => {
const next = new Set(prev ?? []);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
setPage(1);
clearSelection();
};
const toggleFile = (file: DsLogFile) => {
setSelection((prev) => {
const next = new Map(prev);
if (next.has(file.file_id)) next.delete(file.file_id);
else next.set(file.file_id, file.size_bytes);
return next;
});
};
const togglePageAll = () => {
setSelection((prev) => {
const next = new Map(prev);
if (pageAllSelected) {
for (const item of items) next.delete(item.file_id);
} else {
for (const item of items) next.set(item.file_id, item.size_bytes);
}
return next;
});
};
const startExport = async () => {
if (!canExport) return;
setExporting(true);
setExportError(null);
setExportMessage(null);
const ac = new AbortController();
exportAbortRef.current = ac;
try {
const body = await apiFetch<DsLogExportResponse>(
`/api/ds-devices/${encodeURIComponent(serialNumber)}/logs/exports`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file_ids: [...selection.keys()] }),
signal: ac.signal,
}
);
const anchor = document.createElement("a");
anchor.href = body.download_url;
anchor.download = "";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setExportMessage(
t("ダウンロードを開始しました({size})").replace(
"{size}",
formatFileSize(body.total_bytes)
)
);
} catch (e: unknown) {
const message = apiErrorMessage(e, t);
if (message !== null) setExportError(message);
} finally {
if (exportAbortRef.current === ac) exportAbortRef.current = null;
setExporting(false);
}
};
const issueToken = async () => {
if (tokenBusy) return;
if (
!window.confirm(
t("トークンを発行します。発行済みのトークンは使えなくなります。よろしいですか?")
)
) {
return;
}
setTokenBusy(true);
setTokenError(null);
try {
const body = await apiFetch<DsAgentTokenIssued>(
`/api/ds-devices/${encodeURIComponent(serialNumber)}/agent-token`,
{ method: "POST" }
);
setTokenValue(body.token);
setTokenIssuedAt(body.issued_at);
} catch (e: unknown) {
const message = apiErrorMessage(e, t);
if (message !== null) setTokenError(message);
} finally {
setTokenBusy(false);
}
};
const revokeToken = async () => {
if (tokenBusy) return;
if (
!window.confirm(
t("トークンを失効させます。この制御盤はログを送れなくなります。よろしいですか?")
)
) {
return;
}
setTokenBusy(true);
setTokenError(null);
try {
// ★204 は本文が無いので apiFetch を使わない(4-23・M5)★
const response = await fetch(
`/api/ds-devices/${encodeURIComponent(serialNumber)}/agent-token`,
{ method: "DELETE" }
);
if (!response.ok) {
let message = t("データの取得に失敗しました");
try {
const body = (await response.json()) as { error?: { message?: string } };
if (typeof body.error?.message === "string") message = body.error.message;
} catch {
/* 本文が読めなければ既定 */
}
setTokenError(message);
return;
}
setTokenIssuedAt(null);
setTokenValue(null);
} catch {
setTokenError(t("データの取得に失敗しました"));
} finally {
setTokenBusy(false);
}
};
const copyToken = async () => {
if (!tokenValue) return;
try {
await navigator.clipboard.writeText(tokenValue);
} catch {
/* コピー失敗は黙る(権限拒否など) */
}
};
const lastSeenLabel = lastSeenAt
? t("最終接続 {日時}").replace("{日時}", formatDateTime(lastSeenAt))
: t("最終接続 -");
return (
<div
className={styles.overlay}
onMouseDown={(e) => {
downOnOverlayRef.current = e.target === e.currentTarget;
}}
onClick={(e) => {
const downOnOverlay = downOnOverlayRef.current;
downOnOverlayRef.current = false;
if (shouldCloseOnBackdrop(downOnOverlay, e.target === e.currentTarget)) onClose();
}}
>
<div
ref={panelRef}
className={styles.panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.header}>
<div className={styles.headerMain}>
<div>
<h2 id={titleId} className={styles.title}>
{t("制御盤のログ")}
</h2>
<div className={styles.meta}>
{serialNumber} | {facilityName}
</div>
</div>
<div className={styles.connection}>
<span
className={styles.connectionStatus}
style={{ color: connected ? c.ok : c.ng }}
>
{connected ? t("接続中") : t("未接続")}
</span>
<span className={styles.connectionSeen}>{lastSeenLabel}</span>
</div>
</div>
<button
ref={initialFocusRef}
type="button"
className={styles.close}
onClick={onClose}
aria-label={t("閉じる")}
>
✕
</button>
</div>
<div className={styles.body}>
<section className={styles.section}>
<div className={styles.sectionLabel}>{t("種類")}</div>
<div className={styles.typeList}>
{types.length === 0 && !loading ? (
<span className={styles.hint}>{t("種類がまだありません")}</span>
) : (
types.map((type) => (
<label key={type.key} className={styles.checkLabel}>
<input
type="checkbox"
checked={selectedTypes?.has(type.key) ?? false}
onChange={() => toggleType(type.key)}
/>
{/* ★ラベルは生値。t() に通さない★ */}
<span>{type.label}</span>
</label>
))
)}
</div>
</section>
<section className={styles.section}>
<div className={styles.sectionLabel}>{t("期間")}</div>
<div className={styles.rowWrap}>
{(
[
["1h", "直近1時間"],
["3h", "直近3時間"],
["24h", "直近24時間"],
["all", "全て"],
] as const
).map(([preset, label]) => (
<span key={preset} className={styles.fixedSlotPeriod}>
<FilterButton
active={period.kind === "preset" && period.preset === preset}
color={c.pr}
onClick={() => changePeriodPreset(preset)}
>
{t(label)}
</FilterButton>
</span>
))}
<input
type="date"
className={styles.dateInput}
aria-label={t("開始日")}
value={period.kind === "dates" ? period.start : ""}
onChange={(e) => changeDate("start", e.target.value)}
onClick={openDatePicker}
/>
<span className={styles.dateSep}>–</span>
<input
type="date"
className={styles.dateInput}
aria-label={t("終了日")}
value={period.kind === "dates" ? period.end : ""}
onChange={(e) => changeDate("end", e.target.value)}
onClick={openDatePicker}
/>
</div>
</section>
<section className={styles.section}>
<div className={styles.sectionLabel}>{t("並べ替え")}</div>
<div className={styles.rowWrap}>
<span className={styles.fixedSlotSort}>
<FilterButton
active={sort === "date_desc"}
color={c.pr}
onClick={() => {
setSort("date_desc");
setPage(1);
}}
>
{t("日付")}
</FilterButton>
</span>
<span className={styles.fixedSlotSort}>
<FilterButton
active={sort === "type_date_desc"}
color={c.pr}
onClick={() => {
setSort("type_date_desc");
setPage(1);
}}
>
{t("種類+日付")}
</FilterButton>
</span>
</div>
</section>
{truncated && !loading && !listError && (
<div className={styles.notice}>
{t("件数が多いため、新しいものから一部だけを表示しています")}
</div>
)}
{loading && <div className={styles.message}>{t("読み込み中…")}</div>}
{!loading && listError && <div className={styles.error}>{t(listError)}</div>}
{!loading && !listError && items.length === 0 && (
<div className={styles.message}>{t("該当するファイルはありません")}</div>
)}
{!loading && !listError && items.length > 0 && (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
{/* ★チェックの列はチェックボックスだけの幅★(ユーザー指示 2026-11-06「チェックボックスとファイル名が間があきすぎ」)。
「このページをすべて選択」は見出しに文字で出さず、読み上げ(aria-label)とマウスを載せたときの title で出す */}
<th className={styles.thCheck}>
<input
type="checkbox"
checked={pageAllSelected}
onChange={togglePageAll}
aria-label={t("このページをすべて選択")}
title={t("このページをすべて選択")}
/>
</th>
<th className={styles.th}>{t("ファイル名")}</th>
<th className={styles.th}>{t("種類")}</th>
<th className={styles.th}>{t("大きさ")}</th>
<th className={styles.th}>{t("更新日時")}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.file_id}>
<td className={styles.tdCheck}>
<input
type="checkbox"
checked={selection.has(item.file_id)}
onChange={() => toggleFile(item)}
aria-label={item.name}
/>
</td>
<td className={styles.tdName} title={item.name}>
{item.name}
</td>
<td className={styles.td}>
{typeLabels.get(item.type) ?? item.type}
</td>
<td className={styles.tdSize}>{formatFileSize(item.size_bytes)}</td>
<td className={styles.tdDate}>{formatDateTime(item.modified_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!loading && !listError && (
<div className={styles.pagerRow}>
<Pagination
total={total}
page={page}
perPage={perPage}
onPageChange={setPage}
/>
<label className={styles.perPage}>
<span>{t("ページあたり")}</span>
<select
value={perPage}
onChange={(e) => {
setPerPage(Number(e.target.value));
setPage(1);
}}
>
{DS_LOG_PER_PAGE_OPTIONS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</div>
)}
<div className={styles.selectionBar}>
<span>
{t("選択: {n}件・合計 {size}")
.replace("{n}", String(selectedCount))
.replace("{size}", formatFileSize(selectedBytes))}
</span>
{overMaxBytes && (
<span className={styles.warn}>
{t("1回に取り出せるのは {max} までです").replace(
"{max}",
formatFileSize(EXPORT_MAX_BYTES)
)}
</span>
)}
{overMaxFiles && (
<span className={styles.warn}>
{t("1回に選べるのは {n} 件までです").replace(
"{n}",
String(EXPORT_MAX_FILES)
)}
</span>
)}
<button
type="button"
className={styles.exportButton}
disabled={!canExport}
onClick={() => void startExport()}
>
{exporting ? t("制御盤で準備中…") : t("エクスポート")}
</button>
</div>
{exportMessage && <div className={styles.success}>{exportMessage}</div>}
{exportError && <div className={styles.error}>{exportError}</div>}
<section className={styles.tokenSection}>
<div className={styles.tokenHeader}>
<span className={styles.sectionLabel}>{t("制御盤トークン")}</span>
{tokenLoading ? (
<span className={styles.hint}>{t("読み込み中…")}</span>
) : tokenIssuedAt ? (
<span className={styles.hint}>
{t("発行日時 {日時}").replace("{日時}", formatDateTime(tokenIssuedAt))}
</span>
) : (
<span className={styles.hint}>{t("未発行")}</span>
)}
</div>
<div className={styles.tokenActions}>
<button
type="button"
className={styles.tokenButton}
disabled={tokenBusy}
onClick={() => void issueToken()}
>
{t("発行(作り直し)")}
</button>
<button
type="button"
className={styles.tokenButton}
disabled={tokenBusy || tokenIssuedAt === null}
onClick={() => void revokeToken()}
>
{t("失効")}
</button>
</div>
{tokenValue !== null && (
<div className={styles.tokenReveal}>
<input
className={styles.tokenInput}
type="text"
readOnly
value={tokenValue}
aria-label={t("制御盤トークン")}
/>
<button
type="button"
className={styles.tokenButton}
onClick={() => void copyToken()}
>
{t("コピー")}
</button>
<p className={styles.tokenWarn}>
{t("この値は二度と表示されません。制御盤の設定ファイルに入れてください")}
</p>
</div>
)}
{tokenError && <div className={styles.error}>{tokenError}</div>}
</section>
</div>
<div className={styles.footer}>
<button type="button" className={styles.closeBtn} onClick={onClose}>
{t("閉じる")}
</button>
</div>
</div>
</div>
);
}