telecomkz_scraper/src/components/Sidebar.tsx
Iliyas Kyrykbayev 5cb347a44b TelecomKz Analytics Mapper: audit fixes, real event keys, 32 mapped screens
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>
2026-08-24 18:16:46 +05:00

398 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};