diff --git a/projects/frontend/apps/experiment-portal/src/App.scss b/projects/frontend/apps/experiment-portal/src/App.scss index 73272bed..38755ed3 100644 --- a/projects/frontend/apps/experiment-portal/src/App.scss +++ b/projects/frontend/apps/experiment-portal/src/App.scss @@ -46,7 +46,7 @@ min-height: 40px; padding: 0 18px; border: 1px solid var(--border-soft); - border-radius: var(--radius-sm); + border-radius: var(--radius-pill); background: #fff; color: var(--text); font-size: 0.875rem; diff --git a/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.scss b/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.scss new file mode 100644 index 00000000..8f1f877e --- /dev/null +++ b/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.scss @@ -0,0 +1,76 @@ +@import '../../styles/colors'; + +.liveswitch { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 12px; + border-radius: var(--radius-pill); + border: 1px solid $input-border; + background: #fff; + user-select: none; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; + flex: 0 0 auto; + white-space: nowrap; + + &:hover { border-color: $outline-muted; } + + &:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-color: $primary; + } +} + +.liveswitch__track { + position: relative; + width: 32px; + height: 18px; + border-radius: 999px; + background: $input-border; + transition: background 0.2s ease; + flex: none; +} + +.liveswitch__thumb { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + background: #fff; + border-radius: 50%; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.2); + transition: transform 0.2s ease; +} + +.liveswitch--on .liveswitch__track { background: $primary; } +.liveswitch--on .liveswitch__thumb { transform: translateX(14px); } + +.liveswitch__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: $input-border; + flex: none; + transition: background 0.2s ease, box-shadow 0.2s ease; +} + +.liveswitch--on .liveswitch__dot { + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); + animation: live-pulse 1.6s ease-in-out infinite; +} + +@keyframes live-pulse { + 0%, 100% { box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); } + 50% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.06); } +} + +.liveswitch__label { + font-size: 0.82rem; + font-weight: 600; + color: $text; +} diff --git a/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.tsx b/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.tsx new file mode 100644 index 00000000..5c66036d --- /dev/null +++ b/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.tsx @@ -0,0 +1,35 @@ +import './LiveSwitch.scss' + +interface LiveSwitchProps { + live: boolean + onChange: (live: boolean) => void + labelOn?: string + labelOff?: string +} + +function LiveSwitch({ live, onChange, labelOn = 'Live', labelOff = 'History' }: LiveSwitchProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + onChange(!live) + } + } + return ( +
onChange(!live)} + onKeyDown={handleKeyDown} + > +
+
+
+ + {live ? labelOn : labelOff} +
+ ) +} + +export default LiveSwitch diff --git a/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.scss b/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.scss index 0429382c..0fbf27f4 100644 --- a/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.scss +++ b/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.scss @@ -165,3 +165,110 @@ padding-bottom: 9px; font-size: 0.88rem; } + +// ── Pill variant (B1 capsule style) ── +.md-select--pill .md-select__label { + display: none; +} + +.md-select--pill .md-select__control { + min-height: 40px; + border-radius: var(--radius-pill); + border-color: $input-border; + background: #fff; +} + +.md-select--pill .md-select__control:hover { + border-color: $outline-muted; + background: #f8fafc; +} + +.md-select--pill .md-select__control--open { + border-color: $primary; + box-shadow: var(--focus-ring); + background: #fff; +} + +.md-select--pill .md-select__control--disabled { + opacity: 0.62; + background: $surface-variant; +} + +.md-select--pill .md-select__trigger { + position: relative; + min-height: 40px; + padding: 0 28px 0 12px; + font-size: 0.85rem; + border-radius: var(--radius-pill); + gap: 6px; +} + +.md-select--pill.md-select--has-icon .md-select__trigger { + padding-left: 10px; +} + +.md-select__pill-icon { + color: $text-secondary; + flex: none; + display: flex; + align-items: center; + transition: color 0.15s ease; +} + +.md-select--pill .md-select__control--open .md-select__pill-icon { + color: $primary; +} + +.md-select--pill .md-select__icon { + right: 10px; + width: 14px; + height: 14px; + color: $text-secondary; + transition: transform 0.2s ease, color 0.15s ease; +} + +.md-select--pill .md-select__control--open .md-select__icon { + color: $primary; +} + +.md-select__float-label { + position: absolute; + left: 36px; + top: 50%; + transform: translateY(-50%); + font-size: 0.85rem; + color: $text-secondary; + pointer-events: none; + background: #fff; + padding: 0 3px; + transition: top 0.15s ease, font-size 0.15s ease, color 0.15s ease, left 0.15s ease, transform 0.15s ease; + white-space: nowrap; +} + +.md-select--pill:not(.md-select--has-icon) .md-select__float-label { + left: 12px; +} + +.md-select__float-label.is-floating { + top: 0; + transform: translateY(-50%); + font-size: 0.63rem; + font-weight: 600; + letter-spacing: 0.04em; + color: $primary; + left: 10px; +} + +.md-select--pill .md-select__control--open .md-select__float-label { + top: 0; + transform: translateY(-50%); + font-size: 0.63rem; + font-weight: 600; + letter-spacing: 0.04em; + color: $primary; + left: 10px; +} + +.md-select--pill .md-select__value { + font-size: 0.85rem; +} diff --git a/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.tsx b/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.tsx index 1de64d98..d607ccfe 100644 --- a/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.tsx +++ b/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.tsx @@ -27,6 +27,8 @@ type MaterialSelectProps = { multiple?: boolean size?: number placeholder?: string + icon?: ReactNode + variant?: 'default' | 'pill' } function MaterialSelect({ @@ -43,6 +45,8 @@ function MaterialSelect({ multiple = false, size, placeholder = '', + icon, + variant = 'default', }: MaterialSelectProps) { const [isOpen, setIsOpen] = useState(false) const menuRef = useRef(null) @@ -202,9 +206,19 @@ function MaterialSelect({ setIsOpen(false) } } + const isPill = variant === 'pill' + const rootClass = [ + 'md-select', + isPill ? 'md-select--pill' : '', + isPill && icon ? 'md-select--has-icon' : '', + className, + ] + .filter(Boolean) + .join(' ') + return ( -
- {label && ( +
+ {label && !isPill && ( @@ -233,17 +247,30 @@ function MaterialSelect({ id={id} ref={triggerRef} type="button" - className="md-select__trigger" + className={`md-select__trigger${isPill ? ' md-select__trigger--pill' : ''}`} onClick={toggleMenu} onKeyDown={handleTriggerKeyDown} disabled={isDisabled} aria-haspopup="listbox" aria-expanded={isOpen} aria-controls={listboxId} + aria-label={isPill && label ? label : undefined} > + {isPill && icon && ( + {icon} + )} - {selectedLabel || placeholder} + {isPill ? (selectedValue ? selectedLabel : '') : (selectedLabel || placeholder)} + {isPill && label && ( + + {label} + + )}

Аудит-лог

-
-
-
- - setDraftActorId(e.target.value)} - /> -
-
- - setDraftAction(e.target.value)} - /> -
-
- - setDraftScopeType(e.target.value)} - /> -
-
- - setDraftFrom(e.target.value)} - /> -
-
- - setDraftTo(e.target.value)} - /> -
+
+
+ + setDraftAction(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleApply()} + aria-label="Фильтр по действию" + />
-
- - +
+ + setDraftActorId(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleApply()} + aria-label="Фильтр по пользователю" + /> +
+ setDraftScopeType(v)} + variant="pill" + icon={} + > + + + + +
+ setDraftFrom(e.target.value)} + aria-label="Дата с" + /> + + setDraftTo(e.target.value)} + aria-label="Дата по" + />
+ +
{isLoading && } diff --git a/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.scss b/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.scss index 637cc6d1..77245e36 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.scss +++ b/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.scss @@ -109,6 +109,17 @@ color: var(--primary-hover); } +.experiments-filter-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.experiments-filter-row .experiments-filter-actions { + flex: 0 0 auto; +} + @media (max-width: 720px) { .filter-panel__header, .experiments-filter-actions, @@ -117,4 +128,17 @@ flex-direction: column; align-items: stretch; } + + .experiments-filter-row { + flex-direction: column; + align-items: stretch; + } + .experiments-filter-row .filter-capsule { + flex-wrap: wrap; + border-radius: 18px; + padding: 8px; + } + .experiments-filter-row .filter-capsule > * { + flex: 1 1 45%; + } } diff --git a/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.tsx b/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.tsx index f6c314fc..c020ff7c 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.tsx +++ b/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.tsx @@ -114,80 +114,39 @@ function ExperimentsList() { {!isBusy && !error && ( <> -
-
-
-
Command Filters
-

- Отберите нужный проект, статус или найдите эксперимент по названию и описанию. -

-
-
- - -
-
- -
-
- - +
+
+
+ { - setSearchQuery(e.target.value) - setPage(1) - }} + onChange={(e) => { setSearchQuery(e.target.value); setPage(1) }} disabled={isBusy} />
- { - setProjectId(id) - setActiveProjectId(id) - setPage(1) - }} + onChange={(id) => { setProjectId(id); setActiveProjectId(id); setPage(1) }} disabled={isBusy} + variant="pill" + icon={} > {projectsData?.projects?.map((project) => ( - + ))} - { - setStatus(value) - setPage(1) - }} + onChange={(value) => { setStatus(value); setPage(1) }} disabled={isBusy} + variant="pill" + icon={} > @@ -197,6 +156,14 @@ function ExperimentsList() {
+
+ + +
{data && ( diff --git a/projects/frontend/apps/experiment-portal/src/pages/Login.scss b/projects/frontend/apps/experiment-portal/src/pages/Login.scss index 5c365eff..57948e33 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/Login.scss +++ b/projects/frontend/apps/experiment-portal/src/pages/Login.scss @@ -16,3 +16,7 @@ color: var(--text-secondary); font-size: 0.9rem; } + +.login-input-field input { + min-height: unset; +} diff --git a/projects/frontend/apps/experiment-portal/src/pages/Login.tsx b/projects/frontend/apps/experiment-portal/src/pages/Login.tsx index fdaf2631..5e39e548 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/Login.tsx +++ b/projects/frontend/apps/experiment-portal/src/pages/Login.tsx @@ -102,30 +102,36 @@ function Login() {
- setFormData({ ...formData, username: e.target.value })} - required - autoComplete="username" - placeholder="Введите имя пользователя" - disabled={loginMutation.isPending} - /> +
+ + setFormData({ ...formData, username: e.target.value })} + required + autoComplete="username" + placeholder="Введите имя пользователя" + disabled={loginMutation.isPending} + /> +
- setFormData({ ...formData, password: e.target.value })} - required - autoComplete="current-password" - placeholder="Введите пароль" - disabled={loginMutation.isPending} - /> +
+ + setFormData({ ...formData, password: e.target.value })} + required + autoComplete="current-password" + placeholder="Введите пароль" + disabled={loginMutation.isPending} + /> +
{isLoading && } diff --git a/projects/frontend/apps/experiment-portal/src/pages/Scripts.tsx b/projects/frontend/apps/experiment-portal/src/pages/Scripts.tsx index 8f0512bc..82e551a5 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/Scripts.tsx +++ b/projects/frontend/apps/experiment-portal/src/pages/Scripts.tsx @@ -5,7 +5,7 @@ import { scriptsApi } from '../api/scripts' import type { Script, ScriptExecution, ExecutionStatus, ScriptType } from '../types/scripts' import { usePermissions } from '../hooks/usePermissions' import PermissionGate from '../components/PermissionGate' -import { Loading, Error as ErrorComponent, EmptyState } from '../components/common' +import { Loading, Error as ErrorComponent, EmptyState, MaterialSelect, LiveSwitch } from '../components/common' import Modal from '../components/Modal' import { notifySuccess, notifyError } from '../utils/notify' import './Scripts.scss' @@ -698,40 +698,31 @@ function RegistryTab({ onScriptExecuted: _onScriptExecuted }: RegistryTabProps) return ( <> -
-
-
- - setFilterService(e.target.value)} - /> -
-
- -
-
-
- - - +
+
+ + setFilterService(e.target.value)} + aria-label="Фильтр по сервису" + />
+ setFilterActive(on ? true : undefined)} + labelOn="Активные" + labelOff="Все" + /> + + +
{isLoading && } @@ -900,35 +891,31 @@ function ExecutionsTab({ onTabChange: _onTabChange }: ExecutionsTabProps) { return ( <> -
-
-
- - -
-
-
- - - -
+
+ setStatusFilter(v)} + variant="pill" + icon={} + > + + + + + + + + + + +
{exLoading && } diff --git a/projects/frontend/apps/experiment-portal/src/pages/SensorMonitor.tsx b/projects/frontend/apps/experiment-portal/src/pages/SensorMonitor.tsx index 96efe5a3..eceb72f5 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/SensorMonitor.tsx +++ b/projects/frontend/apps/experiment-portal/src/pages/SensorMonitor.tsx @@ -82,44 +82,33 @@ function SensorMonitor() { <> {statusSummary && } -
-
-
-
Фильтры монитора
-

- Выберите проект и отфильтруйте датчики по статусу подключения. -

-
-
-
- setProjectId(id)} - disabled={projectsLoading} - > - - {projectsData?.projects.map((p) => ( - - ))} - - - - {CONNECTION_STATUS_OPTIONS.map((opt) => ( - - ))} - -
+
+ setProjectId(id)} + disabled={projectsLoading} + variant="pill" + icon={} + > + + {projectsData?.projects.map((p) => ( + + ))} + + } + > + {CONNECTION_STATUS_OPTIONS.map((opt) => ( + + ))} +
{filteredSensors.length === 0 && ( diff --git a/projects/frontend/apps/experiment-portal/src/pages/SensorsList.scss b/projects/frontend/apps/experiment-portal/src/pages/SensorsList.scss index f63b051e..a37e5d2a 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/SensorsList.scss +++ b/projects/frontend/apps/experiment-portal/src/pages/SensorsList.scss @@ -1,4 +1,5 @@ @import '../styles/components'; +@import '../styles/colors'; .sensors-list { display: flex; @@ -139,3 +140,13 @@ grid-column: span 12; } } + +.sensors-filter-capsule { + flex-wrap: wrap; + + @media (max-width: 900px) { + border-radius: 18px; + padding: 8px; + > * { flex: 1 1 45%; } + } +} diff --git a/projects/frontend/apps/experiment-portal/src/pages/SensorsList.tsx b/projects/frontend/apps/experiment-portal/src/pages/SensorsList.tsx index 67817491..163e8292 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/SensorsList.tsx +++ b/projects/frontend/apps/experiment-portal/src/pages/SensorsList.tsx @@ -25,6 +25,7 @@ function SensorsList() { const navigate = useNavigate() const [projectId, setProjectId] = useState('') const [status, setStatus] = useState('') + const [searchQuery, setSearchQuery] = useState('') const [page, setPage] = useState(1) const [selectedSensorId, setSelectedSensorId] = useState(null) const pageSize = 20 @@ -53,6 +54,10 @@ function SensorsList() { refetchInterval: 30_000, }) + const filteredSensors = data?.sensors?.filter(s => + !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) ?? [] + const formatLastHeartbeat = (heartbeat?: string | null) => { if (!heartbeat) return 'Никогда' @@ -93,58 +98,51 @@ function SensorsList() { <> {statusSummary && } -
-
-
-
Fleet Filters
-

- Переключайтесь между проектами и быстро отсекайте устройства по рабочему статусу. -

-
-
- -
- { - setProjectId(id) - setActiveProjectId(id) - setPage(1) - }} - disabled={projectsLoading || isLoading} - > - - {projectsData?.projects.map((project) => ( - - ))} - - { - setStatus(value) - setPage(1) - }} - disabled={isLoading} - > - - - - - - +
+
+ + setSearchQuery(e.target.value)} + />
+ { setProjectId(id); setActiveProjectId(id); setPage(1) }} + disabled={projectsLoading || isLoading} + variant="pill" + icon={} + > + + {projectsData?.projects.map((project) => ( + + ))} + + { setStatus(value); setPage(1) }} + disabled={isLoading} + variant="pill" + icon={} + > + + + + + +
{data && ( <>
- {data.sensors.map((sensor: Sensor) => ( + {filteredSensors.map((sensor: Sensor) => (
- {data.sensors.length === 0 && } + {filteredSensors.length === 0 && }
-
-
- Mode - {telemetryModeLabel} -
-
- Project - {selectedProjectName} -
-
- Run - {selectedRunName} -
-
-
-
-
-
- -
- - -
-
- +
+ setViewMode(live ? 'live' : 'history')} + /> } > {projectsData?.projects.map((project) => ( ))} - } > {experiments.map((experiment) => ( ))} - } > {runs.map((run) => ( ))} +
+
+
{canManageCaptureSession && (