Тонкости настройки экранов статуса заказа

image-loader.svg

Наша команда разработки начала работать с телевизорами два года назад. В тот момент казалось, что эта тема не может быть кому-то интересна, так как больше похожа на пляски с бубном, чем на нормальную работу разработчика. Но недавно в телеграм-чате спросили, как работают наши экраны статуса заказа, и сказали, что об этом будет интересно почитать на Хабре. Так появилась эта статья и развёрнутый ответ.

Про дизайн экранов со статусом заказа и почему пришлось его менять

Задолго до того, как мы начали этим заниматься, в пиццериях уже висели телевизоры, сообщающие о готовности заказов:

Это были обычные домашние телевизоры LG модели 32LF580U со SmartTV, в которых открывался встроенный браузер, внутри которого уже открывался экран статуса заказа.

Интерфейс сделали давно, ещё в 2013 году. В то время мало кто из других компаний использовал подобные решения, и общепринятого пользовательского опыта не было. К 2019 году такие экраны появились в крупных сетях, таких как KFC и McDonald«s, и они, по сути, продиктовали рынку визуальный паттерн:

image-loader.svg

Разделение экрана на готовящиеся заказы и готовые стало в какой-то момент рыночным стандартом. А табло, которое просто оповещало, что заказ готов, осталось более типичным для бургерных и нам подходить перестало. Финальной точкой послужил запуск нашего старого табло в Нигерии, где люди трактовали появление своего номера заказа на экране как сигнал о том, что заказ только что взяли в работу.

Стало ясно, что старый дизайн никуда не годится. Наш дизайнер Мурад Гаммадов разработал новый, который мы и принялись воплощать в жизнь.

image-loader.svg

Пишем новый сервис

В 2019 году в Dodo было больше сервисов, чем команд разработки, поэтому мы постоянно ротировались между собой. В какой-то момент в рамках борьбы с техдолгом у нас сложилось правило, что если ты заходишь в новый компонент с фичами, то его нужно отрефакторить. Мы посмотрели на старый дизайн и поняли, что отрефакторить не получится. Вот почему:

  • фронт был написан на JavaScript и Angular, а у нас в техрадаре в качестве принятой технологии стоял TypeScript и React;

  • сам код фронта был размазан по HTML и JS-скриптам и ангуляровскими модулями;

  • фронтенд опрашивал бэк простым поллингом, и получалось, что каждый новый телевизор в пиццерии увеличивал нагрузку на нашу систему;

  • код бэка находился в монолите и просто проксировал запросы в другую часть системы (трекер), создавая тем самым высокую нагрузку на него. По сути до 80% всей нагрузки.

Поэтому решили переписать сервис в сторонке, вытащить его в Kubernetes и сделать так, чтобы он меньше нагружал трекер.

MVP: полёт нормальный

В минимально жизнеспособной версии мы решили не сильно увлекаться оптимизацией бэка, а для начала просто сделать так, чтобы новый дизайн заработал как можно быстрее в первой пиццерии. 10 января 2020 года мы вернулись после каникул и начали работу над новым сервисом, а 22 января экран с новым дизайном заработал в московской пиццерии на Таганке:

b3a57efc013d63b408dda8593c20aea7.jpeg

В тот момент это была одна из самых новых пиццерий в Москве, там висели профессиональные телевизионные панели Samsung Tizen и мы не поймали каких-либо критичных замечаний. Всё работало так, как было запланировано. Мы начали раскатывать новое решение на остальные пиццерии. 

Старые телевизоры против

Когда более старые пиццерии, работающие с LG 2012 года выпуска, стали включать наше решение, полетели первые проблемы Одна из них была в том, что на этих телевизорах наш экран вообще не запускался, не показывая при этом никакой ошибки. Просто белый экран. Вкручивание логов во фронтенд не помогло разобраться в причинах, так как веб-приложение просто не запускалось на этих телевизорах.

Где-то в кладовой мы смогли откопать такую же модель и принесли ее в офис:

dabdfc6f8d232cd4af8e63e31d6989c7.jpeg

На фото видно одну из многочисленных ошибок, но в тот момент экран просто был белым. 

Подключили клавиатуру и стали перебирать комбинации клавиш, которые теоретически могли бы открыть консоль разработчика. Начиная с Ctrl+Shift+I и заканчивая Shift + Ctrl + J, но это не помогло. В конечном итоге, случайно нажав на сочетание клавиш Alt + Shift + F10, мы открыли меню, через которое можно открыть консоль разработчика:

b1eafc9cbbf7463d36293323f895b4cf.jpeg

Отсюда же узнали, что не хватает полифилов на Promise. В дальнейшем таких проблем встречалось много, и каждый раз они исправлялись последовательным подбором полифилов. На словах это звучит просто, но на деле требовало какого-то невероятного количества времени на поиск информации о том, что же значит тот или иной стектрейс. В результате вышел вот такой список, выстраданный десятками экспериментов:

"@babel/polyfill": "^7.7.0",
"abortcontroller-polyfill": "^1.4.0",
"isomorphic-fetch": "^2.2.1",
"promise-polyfill": "^8.1.3",

Этот телевизор в какой-то момент практически стал для нас эталоном: работает на нём, значит, заработает везде. Этого хватило ровно на две недели, пока не начали подключаться пиццерии со старыми моделями Samsung, на которых консоль разработчика было уже не открыть.

91acffaf674d415b3ce4e2898e762887.jpg

Уже не помню точно всех проблем с Samsung, помню, что на нём не открывался полноэкранный режим по клику мышки. Здесь уже помог сервис Browser Stack, в котором вы по сути арендуете виртуальную машину, на которой стоит нужная вам старинная версия браузера и операционки. Сервис не бесплатный — бесплатные в нём только первые 30 минут в сутки. Остальное за деньги. В итоге проблему с полноэкранным режимом удалось исправить, достав из прошлой версии телевизоров скрипт для развёртывания на полный экран.

declare global {
  interface Document {
     exitFullscreen: () => Promise
     mozCancelFullScreen: () => void
     webkitExitFullscreen: () => void
     fullscreenElement: () => void
     mozFullScreenElement: any
     msFullscreenElement: any
     webkitFullscreenElement: any
     msExitFullscreen: () => void
  }

  interface HTMLElement {
     mozRequestFullScreen: () => any
     msRequestFullscreen: () => any
     webkitRequestFullscreen: (ALLOW_KEYBOARD_INPUT: number) => any
  }
}

const fullScreen = async (): Promise => {
  if (document.documentElement.requestFullscreen) {
     await document.documentElement.requestFullscreen()
  } else if (document.documentElement.msRequestFullscreen) {
     document.documentElement.msRequestFullscreen()
  } else if (document.documentElement.mozRequestFullScreen) {
     document.documentElement.mozRequestFullScreen()
  } else if (document.documentElement.webkitRequestFullscreen) {
     document.documentElement.webkitRequestFullscreen((Element as any).ALLOW_KEYBOARD_INPUT)
  }
}

const fullScreenExit = async (): Promise => {
  if (document.exitFullscreen) {
     await document.exitFullscreen()
  } else if (document.msExitFullscreen) {
     document.msExitFullscreen()
  } else if (document.mozCancelFullScreen) {
     document.mozCancelFullScreen()
  } else if (document.webkitExitFullscreen) {
     document.webkitExitFullscreen()
  }
}

export const toggleFullsreenMode = async (): Promise => {
  if (
     !document.fullscreenElement &&
     !document.mozFullScreenElement &&
     !document.webkitFullscreenElement &&
     !document.msFullscreenElement
  ) {
     await fullScreen()
  } else {
     await fullScreenExit()
  }
}

Где-то через месяц работы мы победили примерно 90% такого рода проблем и начали оптимизацию бэка, поскольку к нашему решению успело подключиться более 400 пиццерий. И они начали довольно ощутимо нагружать трекер.

Оптимизируем работу бэка

Как выглядела схема работы старых экранов статуса заказа:

image-loader.svg

Здесь на схеме трекер — это наша система приготовления заказов. В пиццерии она выглядит как набор планшетов, на которых отображается, какую пиццу и в каком порядке готовить. Надо понимать, что трекер — одна из центральных частей системы, без неё невозможно приготовление заказа. В этой схеме самым главным недостатком было то, что фронт ходил поллингом практически напрямую в трекер и создавал много статической нагрузки на него. Первое, что захотелось сделать — сменить пулл-модель на пуш-модель и использовать для этого WebSocket на фронтенде.

image-loader.svg

Эта схема была бы идеальной, однако:

  • cтарые телевизоры не умеют в веб-сокеты;

  • если использовать SignalR, он вроде как может перейти в режим лонг-поллинг, которым автоматически заменяются сокеты, если телевизор старый. Но он тоже с ходу не заведётся: надо долго и аккуратно впиливать.

В конце концов всё равно надо будет прийти к лонг-поллингу, т.к. мы уже сейчас сильно нагружаемся из-за постоянных запросов, но взяться за это пока не успели. Поэтому текущая схема работы выглядит так:

image-loader.svg

Здесь, пожалуй, нет ничего интересного с точки зрения сложности — схема действительно рабочая, позволила снизить нагрузку на трекер, обеспечила горизонтальную масштабируемость без увеличения нагрузки на трекер и сильно вызвала экономию как на внутрисетевогом трафике, так и на нагрузке на машину, на которой стоял трекер. 

Ещё одним плюсом стал перенос сервиса в Kubernetes. Это дало возможность не только добавлять и удалять мощности в случае необходимости, но и снизить количество работающих инстансов сервиса, так как до этого у нас на каждую страну приходилось по одному инстансу, а стран было 14. После переезда сервис поместился на 3 пода.

Сложности с Redis

Есть одна вещь, которая нас ужасно расстроила — это Redis. В компании ходила дурная слава про этот инструмент. Но мы подумали, что это только потому, что все остальные просто не умеют его готовить.

В начале всё шло неплохо, пока дело не дошло до прода. Почему-то начались рандомные таймауты клиента от сервиса. Мы пошли разбираться внутрь исходников библиотеки StackExchange.Redis, чтобы найти проблему, но не обнаружили ничего, что могло бы вызывать такое странное поведение. Пробовали отладку вот по этой статье. Увидев эти «разборки», наш архитектор Глеб Лесников порекомендовал скопировать решение у соседнего сервиса — HostedService, который переподключает клиента в случае отключения. Но это не помогло.

image-loader.svg

Проблема оказалась в том, что мы хранили слишком большой объём данных по ключу, больше 4 КБ. Снизили объём данных — это исправило проблему переподключений.

Но при нагрузочном тестировании выявилась другая проблема — почему-то начало «подскакивать» количество тредов. Эту проблему мы так и не побороли, просто увеличили количество подов в Kubernetes с 3 до 5 и оставили так.

Где-то пару месяцев назад мы обновили библиотеку StackExchange.Redis и снизили количество подов. Проблема подскакивающих тредов ушла. Что это было, так и осталось загадкой.

Какой вывод можно сделать из этой истории? Думаю, такой, что всё же Redis нужно изучать отдельно и не использовать на авось, ожидая, что он будет работать из коробки. Нужно знать его настройки и попробовать под нагрузкой, прежде чем выкатывать на продакшен. 

Делаем фронт вертикальным

К моменту, когда мы справились с оптимизацией бэка, выяснилось, что в новые концепты пиццерий дизайнеры заложили вертикальный дизайн экранов статуса заказа. Также оказалось, что уже через месяц в Нигерии открывается новая пиццерия, в которой планируется расположить телевизоры вертикально.

image-loader.svg

Сначала показалось здесь, что просто получится применить rotate. На новых браузерах это решение сработало нормально. Однако на «эталонном» телевизоре вёрстка съезжала в правую часть экрана.

60f76a8a46f4c0bc6aa38f5913c1ee5c.jpg

Нужно сказать, для нашей команды это был первый проект, в котором было столько задач на фронтенде. Поэтому мы довольно долго мучались с тем, как же в итоге исправить эту проблему. Даже думали сделать два отдельных приложения под вертикальную и горизонтальную вёрстку.

В Dodo принята практика «трэвелерства» — это когда более опытный разработчик с навыками, например, во фронтенде, ходит по командам и передаёт знания о новой области. И к нам пришла Наташа Гулько, наша фронтенд-разработчица (правда, сейчас она работает в EPAM).

Для начала она предложила всё же отказаться от опасной авантюры с отдельным приложением и попробовать поиграться с размерами экрана. К примеру, при повороте экрана мы изменяем смещение по left и top, а так же меняем местами ширину и высоту в глобальных стилях:

const getSize = (orientation: Orientation) => {
  const screenSize = getScreenSize()
  const magicCoefficient = screenSize.width - screenSize.height

  return orientation === Orientation.portrait
     ? css`
           height: ${screenSize.width}px;
           width: ${screenSize.height}px;
           transform: rotate(-90deg);
           left: ${magicCoefficient / 2}px;
           top: ${-magicCoefficient / 2}px;
       `
     : css`
           height: 100%;
           width: 100%;
           left: 0;
           top: 0;
       `
}

export const ContentStyled = styled.div<{ orientation: Orientation }>`
  ${props => getSize(props.orientation)}
  position: absolute;
  overflow-x: hidden;
  overflow-y: hidden;
`

Это привело к тому, что блоки спозиционировались верно, однако теперь у нас возникла проблема с масштабированием шрифтов. Поэтому мы для каждой ориентации отдельно задавали размеры:

{
  portrait: {
     header: {
        fontSize: calcSizeToRem(88),
        lineHeight: calcSizeToRem(100),
        marginBottom: calcSizeToRem(20),
     },
     number: {
        fontSize: calcSizeToRem(72),
        lineHeight: calcSizeToRem(84),
        borderRadius: calcSizeToRem(12),
        height: calcSizeToRem(108),
        width: calcSizeToRem(192),
        marginRight: calcSizeToRem(36),
        paddingTop: calcSizeToRem(4),
     },
     name: {
        fontSize: calcSizeToRem(72),
        lineHeight: calcSizeToRem(102),
        marginTop: calcSizeToRem(3),
        maxWidth: calcSizeToRem(541),
        paddingTop: calcSizeToRem(4),
     },
     line: {
        marginRight: calcSizeToRem(36),
        marginBottom: calcSizeToRem(20),
     },
     pageIndicator: {
        width: calcSizeToRem(90),
     },
  } as LineSizes,

  landscape: {
     header: {
        fontSize: calcSizeToRem(100),
        lineHeight: calcSizeToRem(100),
        marginBottom: calcSizeToRem(40),
     },
     number: {
        fontSize: calcSizeToRem(84),
        lineHeight: calcSizeToRem(84),
        borderRadius: calcSizeToRem(12),
        height: calcSizeToRem(126),
        width: calcSizeToRem(229),
        marginRight: calcSizeToRem(42),
        paddingTop: calcSizeToRem(4),
     },
     name: {
        fontSize: calcSizeToRem(84),
        lineHeight: calcSizeToRem(102),
        marginTop: calcSizeToRem(8),
        maxWidth: calcSizeToRem(470),
        paddingTop: calcSizeToRem(4),
     },
     line: {
        marginRight: calcSizeToRem(42),
        marginBottom: calcSizeToRem(30),
     },
     pageIndicator: {
        width: calcSizeToRem(108),
     },
  } as LineSizes,

  pageIndicator: {
     height: calcSizeToRem(8),
     marginRight: calcSizeToRem(13),
     borderRadius: calcSizeToRem(12),
  },
  container: {
     paddingTop: calcSizeToRem(68),
     paddingBottom: calcSizeToRem(43),
     paddingHorizontal: calcSizeToRem(92),
  },
  column: {
     paddingRight: calcSizeToRem(80),
  },
  pizzeriaLabel: {
     fontSize: calcSizeToRem(105),
  },
  countryLabel: {
     fontSize: calcSizeToRem(78),
  },
  counterNumber: {
     fontSize: calcSizeToRem(250),
     height: calcSizeToRem(273),
     width: calcSizeToRem(250),
     marginRight: calcSizeToRem(30),
     borderRadius: calcSizeToRem(20),
  },
  numbersRow: {
     marginBottom: calcSizeToRem(40),
  },
  screenRotation: {
     container: {
        width: calcSizeToRem(180),
        height: calcSizeToRem(180),
     },
     image: {
        margin: calcSizeToRem(30),
        width: calcSizeToRem(120),
        height: calcSizeToRem(120),
     },
  },
}

При этом при повороте мы ещё и пересчитываем размеры с учётом того , что ширина изменилась, потому что при повороте размер шрифта и блоков всё ещё остаётся чуть больше, чем нужно.

function calcSizeToRem(px: number): string {
  return `${px / pxToNumber(defaultFontSize)}rem`
}

export const defaultFontSize = '16px'
const defaultHeight = '1080px'

export function pxToNumber(size: string): number {
  const px = -2
  return Number(size.slice(0, px))
}

export function calcFontSize(height: string | number): string {
  const heightNumber = typeof height === 'number' ? height : pxToNumber(height)
  const defaultFontSizeNumber = pxToNumber(defaultFontSize)
  const defaultHeightNumber = pxToNumber(defaultHeight)

  return (heightNumber / defaultHeightNumber) * 
defaultFontSizeNumber + 'px'
}

Выглядит это довольно сложно, но позволяет не делать две отдельных вёрстки с начала до конца, а задать отдельно размеры под каждую.

К тому времени, как мы справились с вертикальной вёрсткой, прошло уже два месяца, как пиццерии начали пользоваться новым решением и накопили ряд замечаний. К примеру, оказалось, что если количество готовых и готовящихся заказов больше пяти, тогда не все заказы помещаются на экране. Ничего страшного — делаем слайдеры. Показываем первые пять заказов, потом через десять секунд следующие пять заказов и так по кругу. Но нужно учесть, что при появлении новых заказов нужно проиграть звук «До-до». Что если в этот момент на экране показана первая страница? Надо дождаться последней и только тогда проиграть звук. А что если накопилось несколько заказов? Сколько раз проиграть звук?

Наташа помогла нам и здесь причесать архитектуру проекта, а заодно покрыть всё тестами так, чтобы это можно было безопасно менять. 

В итоге конечный дизайн стал выглядеть вот так:

image-loader.svg

Цвета стали умеренней, а все заказы, наконец, поместились на экраны. 

Факапы

Первое время мы использовали HTTP, потому что не знали, как быстро сможем получить сертификат. Оказалось, что это делается у нас автоматически, нужно было просто настроить ингресс определённым образом, но это уже другая история. 

В общем, нужно было перевести всех с HTTP на HTTPS. Для этого подняли два ингресcа, обновили фронт так, чтобы он ходил на HTTPS и вырубили ингресс, который слушал клиентов на HTTP. И вдруг получили вот такой график запросов:

image-loader.svg

Он означал, что практически все пиццерии получили примерно вот такую картинку на своих экранах (только с другой ошибкой):

image-loader.svg

Это кажется не страшным, но вообще означает, что в 500 пиццериях кассирам или менеджерам смены придётся оторваться от довольно напряжённой работы на кухне и начать ходить по залу с пультом и перезагружать страницу на телевизоре, перепривязывать устройства и в целом тратить драгоценные человекочасы на то, что не приносит никакой пользы.

Дело оказалось в работе механизма обновления. Он был довольно простой: мы ходили на эндпоинт с версией и получали GUID. Если он менялся по сравнению с предыдущим, значит, нужно сделать обновление страницы на текущей локации. Поскольку локации с HTTP не стало, то и телевизоры после обновления текущей страницы просто получили 404.

Мы сделали более умное обновление, которое проверяет доступность сайта перед тем, как обновить страницу.

export const useReloadOnVersionChange = () => {
  useEffect(() => {
     const fetchData = async (): Promise => {
        const fetchedVersion = await fetchAppVersion()

        if (fetchedVersion) {
           const isUpdateRequired = Boolean(pageProps.bundleVersion && fetchedVersion !== pageProps.bundleVersion)
           if (isUpdateRequired) {
              const isHealthy = await fetchHealthCheck()
              if (isHealthy) {
                 refreshPage()
              }
           }
        } else {
           console.warn('fetchedVersion is not defined')
        }
     }

     const intervalIndex = setIntervalAsync(fetchData, versionUpdatePeriod)

     return () => {
        clearIntervalAsync(intervalIndex)
     }
  }, [])
}

Оно не идеально, но спасает от ситуаций, когда бэк вообще недоступен, например, по причине отсутствия интернета в пиццерии.

Протухшие сертификаты

Последняя вещь, которая была болезненной в работе с телевизорами — это инвалидированные корневые сертификаты. Вы могли буквально недавно видеть вот такие апокалиптичные новости, о том что скоро часть устройств окирпичится. Конечно, это чушь, для владельцев устройств скорее всего не так страшно, а вот для владельцев сайтов — настоящая головная боль. Летом 2020-го произошло переименование компании, которая предоставляла такие корневые сертификаты, из Comodo в Sectigo, и часть пиццерий потеряла возможность показывать своё меню и статусы заказов. 

Сейчас понятно, какую проблему мы встретили, а тогда телевизоры просто показывали белый экран и не было возможности понять, что не так (помним, как не получалось открыть консоль). Последнее обновление для многих вышло в далёком 2013 году, и попросить Samsung или LG выпустить обновление не представлялось возможным. В конце концов мы сменили сертификат на такой, который вёл к другому корневому сертификату, и проблема решилась.

В результате мы сделали вывод, что нам нужно как-то ограничить количество моделей техники и вообще желательно прописывать такие моменты в договорах с вендорами. Сейчас в новые пиццерии и кофейни ставят телевизоры Samsung Tizen, где все эти детали подробно прописаны.

Сложности, достойные упоминания

  • Набор адреса в строке браузера. Вам нужно с помощью стрелочек выбрать нужную букву, а потом нажать ОК. Повторить это действие столько раз, сколько букв в строке. Иногда страница не загружается, строка очищается и всё приходится набирать заново

  • Телевизоры выцветают. Оказывается, что обычный домашний телевизор не рассчитан на круглосуточную работу в жаркой пиццерии, поэтому со временем — сюрприз — он выцветает! Угадайте, кому эта проблема прилетает в виде бага? Правильно, нам!  

  • Не всем странам нравится счётчик пиццерий. Когда на экране нет заказов, мы показываем счётчик открытых пиццерий. В Латвии он почему-то не понравился гостям. Пришлось убирать.

  • Не воспроизводились звуки. На LG 32LF580U в какой-то момент перестал воспроизводиться звук «До-до», и никаких ошибок в консоли не было. При этом на остальных телевизорах звук работал прекрасно. Мы неделю сидели, перебирая варианты от неправильного формата файла до поломки механизма воспроизведения. Оказалось, что blob, на котором лежал звуковой файл, был подписан корневым сертификатом, которого нет в прошивке этой модели, и браузер просто молча игнорировал этот файл.

Выводы

Как вывод будет такой совет: если работаете с телевизорами, старайтесь ограничивать набор моделей на самом первом этапе. Если такой возможности нет, то лучше попробовать что-то вроде Raspberry Pi в качестве устройства. Но знайте, что это тоже может давать нагрузку на саппорт, так как у этой платформы может быть своя специфика настройки, работы и возможных проблем. К примеру перегрев, о котором вы вообще вначале можете не подумать, но в пиццерии бывает жарко, так как весь теплый воздух поднимается вверх. В общем, тоже могут быть проблемы и это не серебряная пуля.

Спасибо, что дочитали статью! Наверняка многие вещи не удалось раскрыть во всей красе, так что я готов ответить в комментариях на вопросы.

© Habrahabr.ru