Rebuilt the capture and mapping pipeline after an audit found the simulator's data could not be trusted: * Hotspot coordinates never matched the screenshots. Capture now scrolls the page over CDP and pastes each frame at the measured scrollY, so image pixels and DOM coordinates share one grid by construction. * Metrics were synthesised (1200 + n*410) and presented as analytics. Numbers are now attached only when the catalog has a matching row; metrics.json carries a `source` label and the UI says "no data" instead of showing zeros. * Event interception hooked a connector bridge that never fires. The app posts to api.amplitude.com using the legacy form-urlencoded v1 API; the hook now reads event_type off the wire. 36 keys are verified as `observed`. * All device access moved into tools/telecom_cdp.py: dynamic WebView socket discovery (the PID was hardcoded), id-matched CDP, measured native geometry. * Editor edits can now be saved to disk; API failures no longer report success from a stale result file; screenId is no longer interpolated into a shell. Screens went from 7 (with fabricated markup) to 32, all verified: image height equals map height, no out-of-bounds hotspots, no dead links. The id_card screenshot has been manually redacted - it showed a national ID. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
import React, { useState } from 'react';
|
||
import type { ScreenData, ViewMode, MetricsSource, DeviceStatus } from '../types/simulator';
|
||
import {
|
||
Smartphone,
|
||
Layers,
|
||
Flame,
|
||
Download,
|
||
Upload,
|
||
Camera,
|
||
Search,
|
||
Database,
|
||
ChevronRight,
|
||
PlusCircle,
|
||
HardDriveDownload,
|
||
AlertTriangle
|
||
} from 'lucide-react';
|
||
|
||
interface SidebarProps {
|
||
screens: ScreenData[];
|
||
currentScreenId: string;
|
||
onSelectScreen: (id: string) => void;
|
||
mode: ViewMode;
|
||
onSetMode: (mode: ViewMode) => void;
|
||
onSyncClickhouse: () => void;
|
||
isSyncing: boolean;
|
||
onExportJson: () => void;
|
||
onImportJson: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||
onTriggerAdbCapture: (screenId?: string) => void;
|
||
onAddNewScreenWithAdb: () => void;
|
||
isCapturing: boolean;
|
||
onSaveAppMap: () => void;
|
||
isSavingMap: boolean;
|
||
isDirty: boolean;
|
||
metricsSource: MetricsSource;
|
||
deviceStatus: DeviceStatus | null;
|
||
}
|
||
|
||
export const Sidebar: React.FC<SidebarProps> = ({
|
||
screens,
|
||
currentScreenId,
|
||
onSelectScreen,
|
||
mode,
|
||
onSetMode,
|
||
onSyncClickhouse,
|
||
isSyncing,
|
||
onExportJson,
|
||
onImportJson,
|
||
onTriggerAdbCapture,
|
||
onAddNewScreenWithAdb,
|
||
isCapturing,
|
||
onSaveAppMap,
|
||
isSavingMap,
|
||
isDirty,
|
||
metricsSource,
|
||
deviceStatus
|
||
}) => {
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
|
||
// Filter screens or hotspots by search
|
||
const filteredScreens = screens.filter(s =>
|
||
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
s.hotspots.some(h =>
|
||
h.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
h.eventKey.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
h.eventNameRu.toLowerCase().includes(searchQuery.toLowerCase())
|
||
)
|
||
);
|
||
|
||
const currentScreen = screens.find(s => s.id === currentScreenId);
|
||
|
||
return (
|
||
<div className="glass-panel" style={{
|
||
width: '350px',
|
||
height: 'calc(100vh - 110px)',
|
||
minHeight: '520px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
padding: '20px',
|
||
gap: '16px'
|
||
}}>
|
||
{/* Mode Switcher Buttons */}
|
||
<div>
|
||
<label style={{ fontSize: '0.75rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '8px', display: 'block' }}>
|
||
Режим просмотра:
|
||
</label>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr 1fr',
|
||
gap: '6px',
|
||
background: 'rgba(0,0,0,0.3)',
|
||
padding: '4px',
|
||
borderRadius: '10px',
|
||
border: '1px solid rgba(255,255,255,0.06)'
|
||
}}>
|
||
<button
|
||
onClick={() => onSetMode('simulator')}
|
||
style={{
|
||
background: mode === 'simulator' ? 'var(--telecom-gradient)' : 'transparent',
|
||
border: 'none',
|
||
color: mode === 'simulator' ? '#fff' : '#94a3b8',
|
||
padding: '8px 4px',
|
||
borderRadius: '8px',
|
||
fontSize: '0.75rem',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
transition: 'all 0.2s ease'
|
||
}}
|
||
>
|
||
<Smartphone size={15} /> Симулятор
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => onSetMode('heatmap')}
|
||
style={{
|
||
background: mode === 'heatmap' ? 'linear-gradient(135deg, #f59e0b 0%, #f43f5e 100%)' : 'transparent',
|
||
border: 'none',
|
||
color: mode === 'heatmap' ? '#fff' : '#94a3b8',
|
||
padding: '8px 4px',
|
||
borderRadius: '8px',
|
||
fontSize: '0.75rem',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
transition: 'all 0.2s ease'
|
||
}}
|
||
>
|
||
<Flame size={15} /> Тепловая
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => onSetMode('editor')}
|
||
style={{
|
||
background: mode === 'editor' ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : 'transparent',
|
||
border: 'none',
|
||
color: mode === 'editor' ? '#fff' : '#94a3b8',
|
||
padding: '8px 4px',
|
||
borderRadius: '8px',
|
||
fontSize: '0.75rem',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
transition: 'all 0.2s ease'
|
||
}}
|
||
>
|
||
<Layers size={15} /> Редактор
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search Input */}
|
||
<div style={{ position: 'relative' }}>
|
||
<Search size={14} color="#64748b" style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)' }} />
|
||
<input
|
||
type="text"
|
||
placeholder="Поиск экранов или событий..."
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '8px 12px 8px 34px',
|
||
background: 'rgba(0,0,0,0.3)',
|
||
border: '1px solid rgba(255,255,255,0.08)',
|
||
borderRadius: '8px',
|
||
color: '#fff',
|
||
fontSize: '0.8rem'
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Screen Selector List */}
|
||
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
||
<label style={{ fontSize: '0.72rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||
Экраны ({filteredScreens.length}):
|
||
</label>
|
||
|
||
<button
|
||
onClick={onAddNewScreenWithAdb}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#00d2ff',
|
||
fontSize: '0.72rem',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
fontWeight: 600
|
||
}}
|
||
>
|
||
<PlusCircle size={13} /> + Новый экран с ADB
|
||
</button>
|
||
</div>
|
||
|
||
{filteredScreens.map(s => {
|
||
const isActive = s.id === currentScreenId;
|
||
const measuredCount = s.hotspots.filter(h => !!h.metrics).length;
|
||
const totalScreenEvents = s.hotspots.reduce((sum, h) => sum + (h.metrics?.totalEvents || 0), 0);
|
||
|
||
return (
|
||
<div
|
||
key={s.id}
|
||
onClick={() => onSelectScreen(s.id)}
|
||
style={{
|
||
padding: '10px 12px',
|
||
borderRadius: '10px',
|
||
background: isActive ? 'rgba(0, 163, 255, 0.15)' : 'rgba(255,255,255,0.02)',
|
||
border: isActive ? '1px solid #00d2ff' : '1px solid rgba(255,255,255,0.05)',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
transition: 'all 0.2s ease'
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: isActive ? '#00d2ff' : '#f1f5f9' }}>
|
||
{s.name}
|
||
</div>
|
||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', gap: '6px', marginTop: '2px', alignItems: 'center' }}>
|
||
<span>{s.hotspots.length} зон</span>
|
||
<span>•</span>
|
||
{measuredCount > 0 ? (
|
||
<span style={{ color: '#10b981', fontFamily: 'var(--font-mono)' }}>
|
||
{totalScreenEvents.toLocaleString('ru-RU')} событий
|
||
</span>
|
||
) : (
|
||
<span style={{ color: '#64748b' }}>без метрик</span>
|
||
)}
|
||
{s.screenshotStale && <AlertTriangle size={11} color="#fbbf24" />}
|
||
</div>
|
||
</div>
|
||
<ChevronRight size={16} color={isActive ? '#00d2ff' : '#475569'} />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Capture Options & Actions */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '14px' }}>
|
||
{/* Persist edits. Export downloads a copy; this writes the file the app reads. */}
|
||
<button
|
||
onClick={onSaveAppMap}
|
||
disabled={isSavingMap || !isDirty}
|
||
style={{
|
||
background: isDirty ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : 'rgba(255,255,255,0.04)',
|
||
border: isDirty ? 'none' : '1px solid rgba(255,255,255,0.08)',
|
||
color: isDirty ? '#fff' : '#64748b',
|
||
padding: '10px',
|
||
borderRadius: '8px',
|
||
fontSize: '0.8rem',
|
||
fontWeight: 700,
|
||
cursor: isDirty && !isSavingMap ? 'pointer' : 'default',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '8px'
|
||
}}
|
||
>
|
||
<HardDriveDownload size={15} />
|
||
{isSavingMap
|
||
? 'Сохраняем...'
|
||
: isDirty
|
||
? 'Сохранить разметку на диск'
|
||
: 'Все изменения сохранены'}
|
||
</button>
|
||
|
||
{/* ADB Capture Button for Selected Screen */}
|
||
<button
|
||
onClick={() => onTriggerAdbCapture(currentScreenId)}
|
||
disabled={isCapturing || !deviceStatus?.webviewReachable}
|
||
style={{
|
||
background: isCapturing ? 'rgba(0, 210, 255, 0.2)' : 'linear-gradient(135deg, #0070f3 0%, #00d2ff 100%)',
|
||
border: 'none',
|
||
color: '#fff',
|
||
padding: '10px',
|
||
borderRadius: '8px',
|
||
fontSize: '0.8rem',
|
||
fontWeight: 700,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '8px',
|
||
boxShadow: '0 4px 15px rgba(0, 163, 255, 0.35)'
|
||
}}
|
||
>
|
||
<Camera size={16} />
|
||
{isCapturing
|
||
? 'Снимаем и размечаем экран...'
|
||
: deviceStatus?.webviewReachable
|
||
? `Снять «${currentScreen?.name || 'текущий экран'}» с ADB`
|
||
: 'Телефон недоступен'}
|
||
</button>
|
||
|
||
{/* ClickHouse Sync Button */}
|
||
<button
|
||
onClick={onSyncClickhouse}
|
||
disabled={isSyncing}
|
||
style={{
|
||
background: 'rgba(16, 185, 129, 0.12)',
|
||
border: '1px solid rgba(16, 185, 129, 0.4)',
|
||
color: '#10b981',
|
||
padding: '8px',
|
||
borderRadius: '8px',
|
||
fontSize: '0.78rem',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '8px'
|
||
}}
|
||
>
|
||
<Database size={14} />
|
||
{isSyncing
|
||
? 'Обновляем метрики...'
|
||
: metricsSource === 'clickhouse'
|
||
? 'Пересчитать метрики (ClickHouse)'
|
||
: 'Пересчитать метрики (демо-данные)'}
|
||
</button>
|
||
|
||
{metricsSource !== 'clickhouse' && (
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: '8px',
|
||
alignItems: 'flex-start',
|
||
fontSize: '0.68rem',
|
||
color: '#fbbf24',
|
||
background: 'rgba(251, 191, 36, 0.08)',
|
||
border: '1px solid rgba(251, 191, 36, 0.22)',
|
||
padding: '8px 10px',
|
||
borderRadius: '8px',
|
||
lineHeight: 1.4
|
||
}}>
|
||
<AlertTriangle size={13} style={{ flexShrink: 0, marginTop: '1px' }} />
|
||
<span>
|
||
Показаны демонстрационные числа, а не данные ClickHouse. Укажите CLICKHOUSE_HOST
|
||
в .env и запустите py tools/fetch_clickhouse.py.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Import & Export JSON buttons */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||
<button
|
||
onClick={onExportJson}
|
||
style={{
|
||
background: 'rgba(255, 255, 255, 0.05)',
|
||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||
color: '#94a3b8',
|
||
padding: '7px',
|
||
borderRadius: '8px',
|
||
fontSize: '0.73rem',
|
||
fontWeight: 500,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '4px'
|
||
}}
|
||
>
|
||
<Download size={12} /> Экспорт JSON
|
||
</button>
|
||
|
||
<label style={{
|
||
background: 'rgba(255, 255, 255, 0.05)',
|
||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||
color: '#94a3b8',
|
||
padding: '7px',
|
||
borderRadius: '8px',
|
||
fontSize: '0.73rem',
|
||
fontWeight: 500,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '4px'
|
||
}}>
|
||
<Upload size={12} /> Импорт
|
||
<input type="file" accept=".json" onChange={onImportJson} style={{ display: 'none' }} />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|