Files
fusion/packages/dashboard/app/components/SettingsSyncLog.tsx
gsxdsm c3d1716fdd refactor(dashboard): split monolithic styles.css into per-component files
Split app/styles.css from ~40k lines down to ~4.5k. Created 56 co-located
component CSS files in app/components/, each imported by its owning .tsx.
The remainder of styles.css holds genuinely global rules (design tokens,
.btn/.card/.modal/.form-input primitives, cross-component @media overrides).

- Lazy-load 13 heavy views (AgentsView, RoadmapsView, NodesView, etc.) via
  React.lazy + Suspense; prefetch all chunks on idle so first navigation is
  instant. Initial JS bundle: 1.58 MB → 1.16 MB (-26%). Initial CSS bundle:
  635 kB → 471 kB (-26%); the rest splits into 13 per-view chunks.

- Add app/test/cssFixture.ts exposing loadAllAppCss() + loadAllAppCssBaseOnly()
  so CSS regression tests load the full per-component bundle (mirroring Vite
  source order). Migrate 30+ tests off direct readFileSync('../styles.css').

- Enable test.css: { include: [/.+/] } in vitest.config.ts so component CSS
  imports actually inject styles in jsdom (fixes getComputedStyle assertions).

- Add ESLint rule (no-restricted-syntax) banning direct styles.css reads in
  dashboard test files; points at loadAllAppCss() instead.

- Restore lost utility classes (.text-muted, .text-secondary, .text-dim,
  .form-input) and rescue dropped chat tool-call rules into QuickChatFAB.css.

- Mobile fixes along the way: scroll containment for view containers
  (min-height:0 + -webkit-overflow-scrolling), QuickChatFAB full-screen on
  mobile (with safe-area-inset for iOS home bar), AgentsView single-row
  header layout, ActivityLogModal close button on right, model-combobox
  z-index above the mobile quick-chat panel.

- Bug fix: SkillsView toggle was display:none which hid the input from the
  accessibility tree; replaced with the visually-hidden pattern so screen
  readers + getByRole still find the checkbox.

- Bug fix: standalone Delete button in TaskDetailModal for triage-column
  tasks (Actions dropdown is hidden in triage state, so previously no way
  to delete a freshly-created task without status change first).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:39:20 -07:00

211 lines
6.7 KiB
TypeScript

import React, { useCallback, useMemo, useState } from "react";
import { ChevronDown, Download, Upload } from "lucide-react";
import "./SettingsSyncLog.css";
// ── Types ─────────────────────────────────────────────────────────────────────
export interface SyncLogEntry {
id: string;
timestamp: string;
direction: "push" | "pull";
result: "success" | "conflict" | "error";
nodeId: string;
nodeName: string;
details?: string;
}
interface SettingsSyncLogProps {
/** The node ID this log is for */
nodeId: string;
/** Sync history entries to display — provided by parent */
entries: SyncLogEntry[];
/** Show loading state */
loading?: boolean;
/** When true, hides the node name filter (used when showing log for a single node) */
singleNode?: boolean;
}
// ── Component ────────────────────────────────────────────────────────────────
/**
* Component that displays recent settings sync operations as a chronological list
* with filtering by direction and node name.
*/
export function SettingsSyncLog({
nodeId: _nodeId,
entries,
loading = false,
singleNode = false,
}: SettingsSyncLogProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [directionFilter, setDirectionFilter] = useState<"all" | "push" | "pull">("all");
const [nodeFilter, setNodeFilter] = useState<string>("all");
// Toggle expanded state
const handleToggle = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
// Extract unique node names for filter dropdown
const uniqueNodes = useMemo(() => {
const nodes = new Set<string>();
for (const entry of entries) {
nodes.add(entry.nodeName);
}
return Array.from(nodes).sort();
}, [entries]);
// Filter entries based on current filters
const filteredEntries = useMemo(() => {
let result = [...entries];
// Filter by direction
if (directionFilter !== "all") {
result = result.filter((entry) => entry.direction === directionFilter);
}
// Filter by node name
if (!singleNode && nodeFilter !== "all") {
result = result.filter((entry) => entry.nodeName === nodeFilter);
}
// Sort by timestamp descending (newest first)
result.sort((a, b) => {
const timeA = new Date(a.timestamp).getTime();
const timeB = new Date(b.timestamp).getTime();
return timeB - timeA;
});
return result;
}, [entries, directionFilter, nodeFilter, singleNode]);
// Format timestamp for display
const formatTimestamp = useCallback((isoTimestamp: string): string => {
const date = new Date(isoTimestamp);
return date.toLocaleString();
}, []);
// Get result badge class
const getResultBadgeClass = useCallback((result: SyncLogEntry["result"]): string => {
switch (result) {
case "success":
return "settings-sync-log__badge--success";
case "conflict":
return "settings-sync-log__badge--conflict";
case "error":
return "settings-sync-log__badge--error";
default:
return "";
}
}, []);
// Get result display text
const getResultText = useCallback((result: SyncLogEntry["result"]): string => {
switch (result) {
case "success":
return "Success";
case "conflict":
return "Conflict";
case "error":
return "Error";
default:
return result;
}
}, []);
return (
<div className="settings-sync-log">
<button
className="settings-sync-log__header"
type="button"
onClick={handleToggle}
aria-expanded={isExpanded}
data-testid="settings-sync-log-header"
>
<ChevronDown
size={16}
className={`settings-sync-log__chevron ${isExpanded ? "settings-sync-log__chevron--expanded" : ""}`}
/>
<span>
{entries.length} {entries.length === 1 ? "entry" : "entries"}
</span>
</button>
{isExpanded && (
<>
<div className="settings-sync-log__filters">
<label>
Direction:
<select
value={directionFilter}
onChange={(e) => setDirectionFilter(e.target.value as "all" | "push" | "pull")}
>
<option value="all">All</option>
<option value="push">Push</option>
<option value="pull">Pull</option>
</select>
</label>
{!singleNode && (
<label>
Node:
<select
value={nodeFilter}
onChange={(e) => setNodeFilter(e.target.value)}
>
<option value="all">All Nodes</option>
{uniqueNodes.map((nodeName) => (
<option key={nodeName} value={nodeName}>
{nodeName}
</option>
))}
</select>
</label>
)}
</div>
{loading && entries.length === 0 ? (
<div className="settings-sync-log__empty">Loading...</div>
) : filteredEntries.length === 0 ? (
<div className="settings-sync-log__empty">No sync history available</div>
) : (
<div className="settings-sync-log__list">
{filteredEntries.map((entry) => (
<div key={entry.id} className="settings-sync-log__entry">
<span className="settings-sync-log__entry-timestamp">
{formatTimestamp(entry.timestamp)}
</span>
<span className="settings-sync-log__entry-direction">
{entry.direction === "push" ? (
<Upload size={14} data-testid="upload-icon" />
) : (
<Download size={14} data-testid="download-icon" />
)}
</span>
<span
className={`settings-sync-log__entry-result ${getResultBadgeClass(entry.result)}`}
>
{getResultText(entry.result)}
</span>
{!singleNode && (
<span className="settings-sync-log__entry-node">{entry.nodeName}</span>
)}
{entry.details && (
<span className="settings-sync-log__entry-details" title={entry.details}>
{entry.details}
</span>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}