Дашборд эксплуатации

  • Рабочие экраны
  • компонентов: 12
  • пакетов: 1

Рабочая консоль команды, которая выкладывает и дежурит, собранная на одном ядре.

Ревизия 2026-09-06

На этой странице
Во всю ширину

Что он доказывает

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

Все компоненты здесь из @feugene/granularity — из ядра. Графика на экране нет, и это осознанно: графики живут в пакете-спутнике, а тот привозит движок отрисовки. Поставить его сюда значило бы показать вес, которого у ядра нет.

Какое решение принимает раскладка

На самой широкой ширине экран — это рельса, шапка, четыре метрики и две колонки. Ниже 1200 пикселей колонки становятся одной: таблица, зажатая в треть планшета, — это таблица, которую никто не читает. Ниже 768 боковое меню уходит совсем, и его место занимает GrBottomNav.

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

Несущая часть — таблица

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

GrDataTable при загрузке отсортирован по сервису. Дашборд, открывающийся несортированным, просит читателя сделать первую работу самому.

Чего здесь нет

На экране нет живых данных, состояния загрузки и пустого состояния — и каждое из этих отсутствий решение, а не упущение. Пример отвечает на вопрос «что можно построить»; состояния настоящего экрана — предмет паттерна обратной связи, где их можно показать рядом, а не по одному.

Использованные компоненты

Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.

Пакеты:

  • @feugene/granularity

Точка входа

Тот же файл, который здесь исполняется, — examples/dashboard/App.vue. Отдельной копии кода не существует.

examples/dashboard/App.vue
<script setup lang="ts">
/**
 * `/examples/dashboard` — рабочий экран эксплуатации, работа C2.
 *
 * Это не витрина компонентов, а ответ на вопрос «что можно построить»
 * (`spec/08` §7). Поэтому здесь нет ни одного элемента, поставленного ради
 * демонстрации: боковое меню, метрики, таблица выкладок и бюджет ошибок — то,
 * из чего такой экран состоит на самом деле.
 *
 * **Импорты гранулярные.** Портал утверждает, что в бандл приезжает только
 * взятое, и пример — единственное место, где это утверждение видно читателю в
 * коде. Бочка `@feugene/granularity` работает так же, но учит противоположному.
 *
 * **Только ядро.** Дашборд без графика выглядит неполным, и это осознанно:
 * `GrChartLine` живёт в спутнике `@feugene/granularity-charts`, а тот тянет
 * движок отрисовки. Затащить его в портал ради одной кривой значило бы
 * показать вес, которого у ядра нет. Пример графиков — работа пакета графиков.
 */
import { computed, ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity/components/GrDataTable'
import { GrDataTable } from '@feugene/granularity/components/GrDataTable'
import { GrNavbar } from '@feugene/granularity/components/GrNavbar'
import { GrSidebar, GrSidebarGroup, GrSidebarItem } from '@feugene/granularity/components/GrSidebar'
import { GrBottomNav } from '@feugene/granularity/components/GrBottomNav'
import { GrCard } from '@feugene/granularity/components/GrCard'
import { GrStatistic } from '@feugene/granularity/components/GrStatistic'
import { GrDelta } from '@feugene/granularity/components/GrDelta'
import { GrBadge } from '@feugene/granularity/components/GrBadge'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrAvatar } from '@feugene/granularity/components/GrAvatar'
import { GrSegmented } from '@feugene/granularity/components/GrSegmented'
import { GrProgressBar } from '@feugene/granularity/components/GrProgressBar'
import type { DashboardData } from './data'

const props = defineProps<{ data: DashboardData }>()

/**
 * Иконки — здесь, а не в содержимом.
 *
 * Иконка у раздела одна на обе локали, и в переводимом файле ей нечего
 * делать: там лежит текст, а не оформление. Сборка это правило больше не
 * подпирает — granum читает классы из всех исходников `src`, и имя из файла
 * содержимого в лист попало бы. До переезда на granum не попадало: лист
 * собирался по модулям клиентской сборки, а содержимое приезжает пропом, и
 * место под иконку оставалось пустым без единой ошибки.
 */
const ICONS: Record<string, string> = {
  overview: 'i-lucide-layout-dashboard',
  deploys: 'i-lucide-rocket',
  incidents: 'i-lucide-siren',
  services: 'i-lucide-boxes',
  team: 'i-lucide-users',
  billing: 'i-lucide-credit-card',
  requests: 'i-lucide-activity',
  latency: 'i-lucide-timer',
  errors: 'i-lucide-triangle-alert',
  cost: 'i-lucide-wallet',
}

const section = ref('overview')
const period = ref(0)
const collapsed = ref(false)

/** Плоский список разделов — нижней навигации группы не нужны. */
const flat = computed(() => props.data.sections.flatMap(group => group.items))

const periodOptions = computed(() => props.data.periods.map((label, value) => ({ value, label })))

const columns = computed<GrDataColumn[]>(() => {
  const { columns: label } = props.data.deploys
  return [
    { key: 'service', label: label.service, sortable: true },
    { key: 'env', label: label.env },
    { key: 'state', label: label.state },
    { key: 'duration', label: label.duration, align: 'right' },
    { key: 'author', label: label.author },
  ]
})

/** Тон бейджа по итогу выкладки. Три состояния, а не два: деградация — не отказ. */
function toneOf(state: string) {
  if (state === 'ok')
    return 'success'
  return state === 'warn' ? 'warning' : 'danger'
}

/** Тон полосы бюджета. Порог — тот, на котором дежурного будят. */
function budgetTone(value: number) {
  if (value < 25)
    return 'danger'
  return value < 50 ? 'warning' : 'success'
}
</script>

<template>
  <div class="screen">
    <GrNavbar :title="data.brand">
      <GrButton size="sm" variant="ghost" square :aria-label="data.labels.search">
        <span class="i-lucide-search block h-4 w-4" aria-hidden="true" />
      </GrButton>

      <GrButton size="sm" variant="ghost" square :aria-label="data.labels.notifications">
        <span class="relative inline-flex">
          <span class="i-lucide-bell block h-4 w-4" aria-hidden="true" />
          <GrBadge size="xs" tone="danger" dark class="absolute -right-2 -top-2">1</GrBadge>
        </span>
      </GrButton>

      <GrAvatar :size="28" :alt="data.labels.account" />
    </GrNavbar>

    <div class="screen__body">
      <!--
        Боковое меню исчезает ниже 768 px, и его место занимает нижняя
        навигация. Это не украшение: боковая колонка на телефоне съедает
        половину ширины таблицы, ради которой на экран и пришли.
      -->
      <GrSidebar
        v-model:collapsed="collapsed"
        class="screen__aside"
        :title="data.brand"
        :subtitle="data.labels.menu"
        show-toggle-button
        landmark="navigation"
        :aria-label="data.labels.menu"
      >
        <GrSidebarGroup v-for="group in data.sections" :key="group.label" :label="group.label">
          <div class="grid gap-1">
            <GrSidebarItem
              v-for="item in group.items"
              :key="item.value"
              :label="item.label"
              :icon="ICONS[item.value]"
              :badge="item.badge"
              :active="item.value === section"
              @click="section = item.value"
            />
          </div>
        </GrSidebarGroup>
      </GrSidebar>

      <main class="screen__main">
        <header class="screen__head">
          <div>
            <h1 class="screen__title">{{ data.title }}</h1>
            <p class="screen__subtitle">{{ data.subtitle }}</p>
          </div>

          <GrSegmented v-model="period" :options="periodOptions" size="sm" />
        </header>

        <div class="screen__metrics">
          <GrCard v-for="metric in data.metrics" :key="metric.key" class="p-4">
            <GrStatistic
              :title="metric.title"
              :value="metric.value"
              :precision="metric.precision"
              :prefix="metric.prefix"
              :suffix="metric.suffix"
              :icon="ICONS[metric.key]"
            />
            <p class="screen__delta">
              <GrDelta :value="metric.delta" :precision="1" suffix="%" />
              <span>{{ data.labels.vsPrevious }}</span>
            </p>
          </GrCard>
        </div>

        <div class="screen__split">
          <GrCard class="p-4">
            <h2 class="screen__section">{{ data.deploys.title }}</h2>

            <!-- Таблица шире телефона всегда. Прокрутка своя, а не у страницы:
                 иначе уезжает весь экран, включая шапку. -->
            <div class="screen__scroll">
              <GrDataTable :rows="data.deploys.rows" :columns="columns" row-key="id" initial-sort-key="service">
                <template #cell-state="{ row }">
                  <GrBadge size="sm" :tone="toneOf(String(row.state))">{{ row.stateLabel }}</GrBadge>
                </template>
              </GrDataTable>
            </div>
          </GrCard>

          <GrCard class="p-4">
            <h2 class="screen__section">{{ data.health.title }}</h2>

            <ul class="screen__health">
              <li v-for="service in data.health.services" :key="service.name">
                <div class="screen__health-row">
                  <span class="screen__health-name">{{ service.name }}</span>
                  <span class="screen__health-note">{{ service.note }}</span>
                </div>
                <!--
                  Имя обязательно: `role="progressbar"` без имени читалка
                  объявляет как «индикатор, 82 процента» — процента чего,
                  неизвестно. Соседний текст ей не помогает, он в другом узле.
                -->
                <GrProgressBar
                  :value="service.value"
                  :tone="budgetTone(service.value)"
                  :aria-label="`${data.health.title}: ${service.name}`"
                  size="sm"
                />
              </li>
            </ul>
          </GrCard>
        </div>
      </main>
    </div>

    <GrBottomNav
      v-model="section"
      class="screen__bottom"
      :items="flat.map(item => ({ label: item.short, value: item.value, icon: ICONS[item.value], badge: item.badge }))"
      position="static"
    />
  </div>
</template>

<style scoped>
.screen {
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
  background: var(--gr-bg);
  color: var(--gr-fg);
}

.screen__body {
  display: flex;
  flex: 1;
  gap: var(--gr-space-4);
  padding: var(--gr-space-4);
}

/* `:deep` — потому что с 0.50 корень `GrSidebar` — обёртка слоя (`display:
   contents`), а класс потребителя уходит на саму панель внутри. Scoped-правило
   родителя достаёт только до корня ребёнка, и без `:deep` панель не прячется. */
:deep(.screen__aside) {
  display: none;
}

/* Колонки — `minmax(0, 1fr)`, а не `1fr`: у `1fr` нижняя граница `auto`, то есть
   минимальная ширина содержимого. Таблица выкладок шире телефона, и колонка
   растягивалась под неё — страница уезжала вбок вместо того, чтобы
   таблица прокручивалась в своей рамке. */
.screen__main {
  display: grid;
  flex: 1;
  grid-template-columns: minmax(0, 1fr);
  align-content: start;
  gap: var(--gr-space-4);
  min-width: 0;
}

/* На телефоне заголовок и переключатель периода стоят друг под другом.
   Строкой они не помещаются, а `flex-wrap` их не переносил: заголовок забирал
   всё место, и переключатель уезжал за край экрана. */
.screen__head {
  display: grid;
  gap: var(--gr-space-3);
}

.screen__title {
  margin: 0;
  font-size: var(--gr-text-xl);
  font-weight: 600;
}

.screen__subtitle {
  margin: var(--gr-space-1) 0 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-sm);
}

.screen__metrics {
  display: grid;
  gap: var(--gr-space-3);
  grid-template-columns: minmax(0, 1fr);
}

.screen__delta {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--gr-space-2);
  margin: var(--gr-space-2) 0 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-xs);
}

.screen__split {
  display: grid;
  gap: var(--gr-space-3);
  grid-template-columns: minmax(0, 1fr);
}

.screen__section {
  margin: 0 0 var(--gr-space-3);
  font-size: var(--gr-text-sm);
  font-weight: 600;
}

.screen__scroll {
  overflow-x: auto;
}

.screen__health {
  display: grid;
  gap: var(--gr-space-3);
  margin: 0;
  padding: 0;
  list-style: none;
}

.screen__health-row {
  display: flex;
  justify-content: space-between;
  gap: var(--gr-space-2);
  margin-bottom: var(--gr-space-1);
  font-size: var(--gr-text-sm);
}

.screen__health-name {
  font-variant-numeric: tabular-nums;
}

.screen__health-note {
  color: var(--gr-muted-fg);
}

/* ── Планшет: метрики парами, боковое меню появляется рельсой ─────────────── */
@media (min-width: 768px) {
  .screen__head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
  }

  .screen__metrics {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  :deep(.screen__aside) {
    display: flex;
  }

  .screen__bottom {
    display: none;
  }
}

/* ── Десктоп: четыре метрики в ряд, таблица и бюджет рядом ────────────────── */
@media (min-width: 1200px) {
  .screen__metrics {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }

  .screen__split {
    grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
  }
}
</style>

Собрать такое же