Дашборд эксплуатации
Рабочая консоль команды, которая выкладывает и дежурит, собранная на одном ядре.
Что он доказывает
Дашборд — экран, который чаще всего показывают как витрину дизайн-системы и чаще всего подделывают: три карточки, график, и ни одного ответа на вопрос, что происходит на 375 пикселях. Этот настоящий в единственном смысле, который что-то значит: на всех трёх ширинах это один и тот же код, а переключатель сверху меняет вьюпорт, а не масштаб.
Все компоненты здесь из @feugene/granularity — из ядра. Графика на экране
нет, и это осознанно: графики живут в пакете-спутнике, а тот привозит движок
отрисовки. Поставить его сюда значило бы показать вес, которого у ядра нет.
Какое решение принимает раскладка
На самой широкой ширине экран — это рельса, шапка, четыре метрики и две
колонки. Ниже 1200 пикселей колонки становятся одной: таблица, зажатая в треть
планшета, — это таблица, которую никто не читает. Ниже 768 боковое меню уходит
совсем, и его место занимает GrBottomNav.
Последняя подмена — самая интересная. Меню не прячется в собственную узкую версию, оно заменяется другим компонентом: вертикальный список из шести разделов на телефоне стоит дороже, чем содержимое, к которому ведёт. Навигация, выживающая за счёт сжатия, обычно перестаёт быть пригодной раньше, чем перестаёт быть видимой.
Несущая часть — таблица
Пять столбцов не помещаются на телефон и не поместятся никогда. Честных вариантов три: убрать столбцы, разложить строку в карточку или дать таблице прокручиваться вбок внутри своей коробки. Экран берёт третий, и коробка здесь — важная половина: когда вбок едет вся страница, вместе с ней уезжает шапка, и читатель теряет место.
GrDataTable при загрузке отсортирован по сервису. Дашборд, открывающийся
несортированным, просит читателя сделать первую работу самому.
Чего здесь нет
На экране нет живых данных, состояния загрузки и пустого состояния — и каждое из этих отсутствий решение, а не упущение. Пример отвечает на вопрос «что можно построить»; состояния настоящего экрана — предмет паттерна обратной связи, где их можно показать рядом, а не по одному.
Использованные компоненты
Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.
GrNavbarНавигацияGrSidebarНавигацияGrBottomNavНавигацияGrCardДанныеGrStatisticДанныеGrDeltaДанныеGrDataTableДанныеGrBadgeОбратная связьGrButtonДействияGrAvatarДанныеGrSegmentedФормыGrProgressBarОбратная связь
Пакеты:
@feugene/granularity
Точка входа
Тот же файл, который здесь исполняется, — 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>