Operations dashboard
A working console for a team that ships and gets paged, built from the core package alone.
What this proves
A dashboard is the screen most often shown as a design system demo and most often faked: three cards, a chart, no answer to what happens at 375 pixels. This one is real in the only sense that counts — it is the same code on all three widths, and the switcher above changes the viewport rather than the zoom.
Every component here comes from @feugene/granularity, the core package. There
is no chart on the screen and that is deliberate: charts live in a companion
package that brings a rendering engine, and putting one here would show you a
weight the core does not carry.
The decision the layout makes
At the widest size the screen is a rail, a header, four metrics and two columns.
Below 1200 pixels the two columns become one, because a table squeezed to a
third of a tablet is a table nobody reads. Below 768 pixels the sidebar leaves
entirely and GrBottomNav takes over.
That last swap is the interesting one. The sidebar is not hidden with a narrower variant of itself — it is replaced by a different component, because a vertical list of six sections on a phone costs more than the content it leads to. Navigation that survives by shrinking usually stops being usable before it stops being visible.
The table is the load-bearing part
Five columns do not fit on a phone and never will. The honest options are three: drop columns, stack each row into a card, or let the table scroll sideways inside its own box. This screen takes the third, and the box is the important half — when the page itself scrolls sideways, the header goes with it and the reader loses their place.
GrDataTable sorts by service on load. A dashboard that opens unsorted asks
the reader to do the first piece of work themselves.
What is not here
There is no live data, no loading state and no empty state on this screen, and each of those is a decision rather than an omission. The example answers “what can I build”; the states of a real screen are the subject of the feedback pattern, where they can be shown side by side instead of one at a time.
Components used
The list is built from frontmatter and checked against the source: a component added to the code and forgotten in the header fails the gate.
GrNavbarnavigationGrSidebarnavigationGrBottomNavnavigationGrCarddataGrStatisticdataGrDeltadataGrDataTabledataGrBadgefeedbackGrButtonactionsGrAvatardataGrSegmentedformsGrProgressBarfeedback
Packages:
@feugene/granularity
Entry point
The same file that runs here — examples/dashboard/App.vue. No second copy of the code exists.
<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>