Rebuild the dashboard around what needs a person
The board was one card per source in a fixed grid: every source got the same small box whatever it had to say (warranties showed 5 of 50), and a box per source flattened priority, while the largest type on the page belonged to counts that need nobody. Now a severity rollup, one worst-first queue of everything needing a person, and a row of context beneath. The queue is full width, capped per source so a noisy check cannot bury a rare one, and names what did not fit with a link to it. Chips filter the queue by check as well as by severity; both narrow what is already loaded, so the counts and rows cannot disagree. Dropped: an 'All assets' tile shown beside its own four addends, a printer strip carrying the same count a third time, three cards whose job was to display 0, and a Recent Devices table listing five machines all 'In Use'. Contract 0.26.0 adds optional 'shortname' on dashboard cards - a title is written as a heading and ran wider than the hostname it qualified as a row badge. Also: the supplies card reported 'Low Supplies 0' when Zabbix was unreachable, which reads as reassurance rather than the truth that nothing could see the cartridges. It now distinguishes unreachable (says so) from unconfigured (stays quiet - the site declined the feature) from a real zero. Full suite 2182 passed, frontend 314 passed.
This commit is contained in:
@@ -1,71 +1,136 @@
|
||||
<template>
|
||||
<div v-if="visibleCards.length" class="dc-grid">
|
||||
<section
|
||||
v-for="card in visibleCards"
|
||||
:key="card.id"
|
||||
class="dc-card"
|
||||
:class="'dc-' + (card.severity || 'info')"
|
||||
>
|
||||
<header class="dc-head">
|
||||
<span class="dc-dot" aria-hidden="true"></span>
|
||||
<h3 class="dc-title">{{ card.title }}</h3>
|
||||
<span v-if="card.render !== 'metric'" class="dc-count">{{ countOf(card) }}</span>
|
||||
</header>
|
||||
<div v-if="hasBoard" class="dc-board">
|
||||
|
||||
<!-- metric: the count IS the story -->
|
||||
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
|
||||
<!-- The rollup. Counts ROWS, not cards, and each count filters the queue
|
||||
below rather than navigating away: landing on a number that tells you
|
||||
whether to keep reading is the one thing an RMM board gets right. -->
|
||||
<div v-if="totalRows" class="dc-attention">
|
||||
<button
|
||||
v-for="level in attentionLevels"
|
||||
:key="level.key"
|
||||
type="button"
|
||||
class="dc-att"
|
||||
:class="['dc-att-' + level.key, { 'dc-att-on': filter === level.key }]"
|
||||
:aria-pressed="filter === level.key"
|
||||
@click="toggleFilter(level.key)"
|
||||
>
|
||||
<span class="dc-att-n">{{ level.count }}</span>
|
||||
<span class="dc-att-label">{{ level.label }}</span>
|
||||
</button>
|
||||
<button v-if="filter || sourcefilter" type="button" class="dc-att-clear"
|
||||
@click="clearFilters">
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- exceptions / list: one line per thing, each linking to itself -->
|
||||
<ul v-else class="dc-rows">
|
||||
<li v-for="(row, index) in visibleRows(card)" :key="index" class="dc-row"
|
||||
:class="{ 'dc-row-stacked': card.layout === 'stacked' }">
|
||||
<!-- With coordinates, the name carries the same floor-plan preview
|
||||
the asset's own page uses. Without them, a plain link. -->
|
||||
<LocationMapTooltip v-if="row.link && row.maphover"
|
||||
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
|
||||
:machineName="row.maphover.label">
|
||||
<router-link :to="row.link" class="dc-row-title"
|
||||
:title="row.titletip || undefined">
|
||||
{{ row.title }}
|
||||
</router-link>
|
||||
</LocationMapTooltip>
|
||||
<router-link v-else-if="row.link" :to="row.link" class="dc-row-title"
|
||||
:title="row.titletip || undefined">
|
||||
{{ row.title }}
|
||||
<!-- What KIND, next to how bad. When five endpoints go quiet at once,
|
||||
setting those aside to look at the crashes is the difference between a
|
||||
board and a list. Chips reflect the severity filter above them, so the
|
||||
two read as one control rather than two that can contradict. -->
|
||||
<div v-if="facets.length > 1" class="dc-facets">
|
||||
<button
|
||||
v-for="facet in facets"
|
||||
:key="facet.key"
|
||||
type="button"
|
||||
class="dc-facet"
|
||||
:class="['dc-facet-' + facet.severity, { 'dc-facet-on': sourcefilter === facet.key }]"
|
||||
:aria-pressed="sourcefilter === facet.key"
|
||||
@click="toggleSource(facet.key)"
|
||||
>
|
||||
{{ facet.label }}
|
||||
<span class="dc-facet-n">{{ facet.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- A check that cannot answer. Not a queue row: there is no asset to act
|
||||
on, and burying "the monitor is dead" among the things it failed to
|
||||
find would be the same mistake in a new place. -->
|
||||
<p v-for="card in unavailable" :key="card.id" class="dc-unavailable">
|
||||
<span class="dc-unavailable-dot" aria-hidden="true"></span>
|
||||
{{ card.title }}: {{ reasonFor(card) }}
|
||||
</p>
|
||||
|
||||
<div class="dc-cols">
|
||||
|
||||
<!-- ONE queue, worst first, instead of a card per source. Reading order
|
||||
is work order. -->
|
||||
<section v-if="queue.length || overflow.length" class="dc-panel dc-queue">
|
||||
<header class="dc-panel-head">
|
||||
<h3>Needs attention</h3>
|
||||
<span class="dc-panel-count">{{ queueTotal }}</span>
|
||||
</header>
|
||||
<ul class="dc-rows">
|
||||
<DashboardRow
|
||||
v-for="row in queue"
|
||||
:key="row.key"
|
||||
:row="row"
|
||||
:severity="row.severity"
|
||||
showsource
|
||||
/>
|
||||
</ul>
|
||||
<!-- What the board could not fit, per source and with somewhere to go.
|
||||
A bare "and 45 more" that leads nowhere is worse than silence. -->
|
||||
<p v-for="entry in overflow" :key="entry.id" class="dc-more">
|
||||
<router-link v-if="entry.viewall" :to="entry.viewall" class="dc-more-link">
|
||||
{{ entry.hidden }} more {{ entry.title.toLowerCase() }}
|
||||
</router-link>
|
||||
<span v-else class="dc-row-title dc-row-nolink"
|
||||
:title="row.titletip || undefined">{{ row.title }}</span>
|
||||
<span v-if="row.detail" class="dc-row-detail"
|
||||
:title="row.detailtip || undefined">{{ row.detail }}</span>
|
||||
<span v-for="(chip, c) in row.chips" :key="c" class="dc-chip"
|
||||
:class="'dc-chip-' + (chip.level || 'low')" :title="chip.title">
|
||||
{{ chip.text }}
|
||||
</span>
|
||||
<span v-if="row.meta.length" class="dc-row-meta">
|
||||
{{ row.meta.map((m) => m.text).join(' / ') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span v-else>{{ entry.hidden }} more {{ entry.title.toLowerCase() }}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- The overflow line goes somewhere. Telling someone 35 more PCs are
|
||||
quiet and leaving them to find the list is worse than not saying it.
|
||||
A card without a viewall still says the count, because the number
|
||||
itself is information. -->
|
||||
<router-link v-if="overflowCount(card) && card.viewall"
|
||||
:to="card.viewall" class="dc-more dc-more-link">
|
||||
and {{ overflowCount(card) }} more
|
||||
</router-link>
|
||||
<p v-else-if="overflowCount(card)" class="dc-more">
|
||||
and {{ overflowCount(card) }} more
|
||||
</p>
|
||||
</section>
|
||||
<section v-else class="dc-panel dc-clear">
|
||||
<p class="dc-clear-line">Nothing needs attention.</p>
|
||||
</section>
|
||||
|
||||
<aside class="dc-rail">
|
||||
<!-- The page owns what sits above the context cards: inventory is not
|
||||
an exception, so it is not this component's to declare. -->
|
||||
<slot name="rail"></slot>
|
||||
|
||||
<section v-for="card in context" :key="card.id" class="dc-panel">
|
||||
<header class="dc-panel-head">
|
||||
<h3>{{ card.title }}</h3>
|
||||
<span class="dc-panel-count">{{ countOf(card) }}</span>
|
||||
</header>
|
||||
|
||||
<p v-if="card.render === 'metric'" class="dc-metric">{{ metricValue(card) }}</p>
|
||||
|
||||
<ul v-else class="dc-rows">
|
||||
<DashboardRow
|
||||
v-for="(row, index) in visibleRows(card)"
|
||||
:key="index"
|
||||
:row="row"
|
||||
:stacked="card.layout === 'stacked'"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<router-link v-if="overflowCount(card) && card.viewall"
|
||||
:to="card.viewall" class="dc-more dc-more-link">
|
||||
and {{ overflowCount(card) }} more
|
||||
</router-link>
|
||||
<p v-else-if="overflowCount(card)" class="dc-more">
|
||||
and {{ overflowCount(card) }} more
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Renders the dashboard cards plugins declare via get_dashboard_widgets
|
||||
// (GET /api/dashboard/widgets). Core owns the renderers; a plugin declares data
|
||||
// and shape. See docs/proposals/dashboard-live-fleet.md.
|
||||
// The fleet dashboard. Plugins declare cards via get_dashboard_widgets
|
||||
// (GET /api/dashboard/widgets); core owns the drawing. See
|
||||
// docs/proposals/dashboard-live-fleet.md.
|
||||
//
|
||||
// LAYOUT: this used to be one card per source in a fixed grid, and both halves
|
||||
// hurt. Every source got the same small box whatever it had to say, so
|
||||
// warranties showed 5 of 50 while a one-row card beside it left a hole; and a
|
||||
// box per source flattened priority, because an enforcement failure and a
|
||||
// warranty notice look equally urgent when each owns a card. Now the
|
||||
// critical and warning rows merge into one worst-first queue, and info cards
|
||||
// sit in a rail as context. The severity driving it was always declared - the
|
||||
// old layout just threw it away.
|
||||
//
|
||||
// Each card fetches INDEPENDENTLY and a failure is swallowed to null, so one
|
||||
// slow or broken endpoint - a hung Zabbix call, a plugin mid-upgrade - cannot
|
||||
@@ -74,16 +139,85 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '../api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
import DashboardRow from '@/components/DashboardRow.vue'
|
||||
import {
|
||||
toApiPath, visibleRows, overflowCount, metricValue, cardVisible, sortCards,
|
||||
toApiPath, visibleRows, overflowCount, metricValue, cardVisible,
|
||||
permittedCards, renderableCards, rows as cardData,
|
||||
queueRows, queueOverflow, contextCards, severityTotals,
|
||||
sourceFacets, unavailableCards, cardUnavailable,
|
||||
} from './dashboardCards'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const cards = ref([])
|
||||
|
||||
const visibleCards = computed(() => sortCards(cards.value.filter(cardVisible)))
|
||||
// Severity filter from the rollup. Empty means show everything.
|
||||
const filter = ref('')
|
||||
|
||||
const liveCards = computed(() => cards.value.filter(cardVisible))
|
||||
|
||||
const totals = computed(() => severityTotals(liveCards.value))
|
||||
|
||||
const attentionLevels = computed(() => {
|
||||
const totalcounts = totals.value
|
||||
return [
|
||||
{ key: 'critical', label: 'Critical', count: totalcounts.critical },
|
||||
{ key: 'warning', label: 'Needs a look', count: totalcounts.warning },
|
||||
{ key: 'info', label: 'For information', count: totalcounts.info },
|
||||
].filter((level) => level.count > 0)
|
||||
})
|
||||
|
||||
const totalRows = computed(
|
||||
() => attentionLevels.value.reduce((sum, level) => sum + level.count, 0))
|
||||
|
||||
// The filter narrows what is already on the board rather than refetching: the
|
||||
// counts and the rows then cannot disagree.
|
||||
const filteredCards = computed(() => (filter.value
|
||||
? liveCards.value.filter((card) => (card.severity || 'info') === filter.value)
|
||||
: liveCards.value))
|
||||
|
||||
// Which check to show, on top of the severity filter. Both narrow what is
|
||||
// already loaded, so no combination can disagree with the counts.
|
||||
const sourcefilter = ref('')
|
||||
|
||||
const facets = computed(() => sourceFacets(filteredCards.value))
|
||||
|
||||
const sourceFiltered = computed(() => (sourcefilter.value
|
||||
? filteredCards.value.filter((card) => card.id === sourcefilter.value)
|
||||
: filteredCards.value))
|
||||
|
||||
const unavailable = computed(() => unavailableCards(liveCards.value))
|
||||
|
||||
const queue = computed(() => queueRows(sourceFiltered.value))
|
||||
const overflow = computed(() => queueOverflow(sourceFiltered.value))
|
||||
const context = computed(
|
||||
() => contextCards(filteredCards.value).filter((card) => !cardUnavailable(card)))
|
||||
|
||||
const queueTotal = computed(
|
||||
() => queue.value.length + overflow.value.reduce((sum, e) => sum + e.hidden, 0))
|
||||
|
||||
const hasBoard = computed(() => liveCards.value.length > 0)
|
||||
|
||||
function toggleFilter(level) {
|
||||
filter.value = filter.value === level ? '' : level
|
||||
// A source chip that the new severity filter excludes would leave the queue
|
||||
// empty with two controls both looking active and neither obviously wrong.
|
||||
if (sourcefilter.value && !facets.value.some((f) => f.key === sourcefilter.value)) {
|
||||
sourcefilter.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSource(key) {
|
||||
sourcefilter.value = sourcefilter.value === key ? '' : key
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filter.value = ''
|
||||
sourcefilter.value = ''
|
||||
}
|
||||
|
||||
function reasonFor(card) {
|
||||
return cardUnavailable(card)
|
||||
}
|
||||
|
||||
function countOf(card) {
|
||||
return cardData(card).length
|
||||
@@ -118,154 +252,191 @@ defineExpose({ load })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dc-grid {
|
||||
display: grid;
|
||||
/* Four across on a wide screen. A card holds a hostname, a machine number
|
||||
and a state on one line, so the track cannot go much below this without
|
||||
truncating the rows that matter most - but 28rem only fits three once the
|
||||
sidebar takes its share at 1920, which is the common case here. auto-fit
|
||||
rather than auto-fill so two cards fill the width instead of leaving
|
||||
empty tracks. */
|
||||
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||
max-width: 120rem;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
align-items: start;
|
||||
.dc-board { margin-bottom: 1.75rem; }
|
||||
|
||||
/* ---- rollup ---- */
|
||||
.dc-attention {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
|
||||
alone this reaches five on an ultrawide, and a fifth column buys nothing -
|
||||
the cards get narrower and the rows they hold start truncating again. */
|
||||
@media (min-width: 96rem) {
|
||||
.dc-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.dc-att {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.dc-card {
|
||||
.dc-att:hover { border-color: var(--text-light); }
|
||||
.dc-att-on { box-shadow: 0 0 0 2px var(--primary) inset; }
|
||||
|
||||
.dc-att-critical { border-left-color: var(--danger); }
|
||||
.dc-att-warning { border-left-color: var(--warning); }
|
||||
.dc-att-info { border-left-color: var(--primary); }
|
||||
|
||||
.dc-att-n {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dc-att-critical .dc-att-n { color: var(--danger); }
|
||||
.dc-att-warning .dc-att-n { color: var(--warning); }
|
||||
.dc-att-info .dc-att-n { color: var(--primary); }
|
||||
|
||||
.dc-att-label { font-size: 0.85rem; color: var(--text-light); }
|
||||
|
||||
.dc-att-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--link);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* ---- source chips ---- */
|
||||
.dc-facets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dc-facet {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.dc-facet:hover { border-color: var(--text-light); }
|
||||
.dc-facet-on {
|
||||
color: var(--text);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px var(--primary) inset;
|
||||
}
|
||||
.dc-facet-critical { border-left: 3px solid var(--danger); }
|
||||
.dc-facet-warning { border-left: 3px solid var(--warning); }
|
||||
|
||||
.dc-facet-n {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---- a check that cannot answer ---- */
|
||||
.dc-unavailable {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--warning);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.dc-unavailable-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background: var(--warning);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ---- two columns ---- */
|
||||
.dc-cols {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 120rem;
|
||||
}
|
||||
|
||||
/* Context sits BELOW the queue, in a row, rather than in a side rail. The
|
||||
queue then gets the full width - which is what "not enough room to relay the
|
||||
data" was about - and a quiet day leaves no column-shaped void where the
|
||||
queue ran out of rows. */
|
||||
.dc-rail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- panels ---- */
|
||||
.dc-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.85rem 1rem 0.9rem;
|
||||
/* Nothing escapes the card. A printer row carrying three cartridge readings
|
||||
plus a location was pushing past the border: a flex child will not shrink
|
||||
below its content width unless told to, so text-overflow never engaged and
|
||||
the row simply overflowed. min-width:0 below is what actually enables the
|
||||
ellipsis; this is the backstop. */
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
/* Nothing escapes the panel: a printer row carrying three cartridge readings
|
||||
plus a location was pushing past the border, because a flex child will not
|
||||
shrink below its content unless told to. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Severity is a small dot beside the title, not a coloured card or a thick
|
||||
bar. A card painted by severity reads as an alert even when it holds one
|
||||
minor row, and six of them read as a crisis - which is how a board stops
|
||||
being read at all. */
|
||||
.dc-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: none; }
|
||||
.dc-critical .dc-dot { background: var(--danger); }
|
||||
.dc-warning .dc-dot { background: var(--warning); }
|
||||
.dc-info .dc-dot { background: var(--primary); }
|
||||
|
||||
.dc-head {
|
||||
.dc-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.dc-title {
|
||||
.dc-panel-head h3 {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.dc-count {
|
||||
font-size: 0.8rem;
|
||||
.dc-panel-count {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dc-metric {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dc-metric { margin: 0.5rem 0 0; font-size: 2rem; font-weight: 600; color: var(--text); }
|
||||
|
||||
/* One row per line, not a wrapped paragraph. Each row is title / detail /
|
||||
meta on a single line that truncates, so ten rows are ten scannable lines
|
||||
rather than a block of text that has to be read. */
|
||||
.dc-rows { list-style: none; margin: 0; padding: 0; }
|
||||
.dc-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.dc-row:last-child { border-bottom: none; }
|
||||
/* Stacked: a label on one line, prose beneath. Inline, the label is truncated
|
||||
to make room for text that is then truncated anyway, and neither reads. */
|
||||
.dc-row-stacked { flex-direction: column; align-items: stretch; gap: 0.15rem; }
|
||||
.dc-row-stacked .dc-row-title { max-width: 100%; }
|
||||
.dc-row-stacked .dc-row-detail { white-space: normal; color: var(--text-light); }
|
||||
.dc-row-stacked .dc-row-meta { margin-left: 0; padding-left: 0; }
|
||||
.dc-row-title {
|
||||
font-weight: 600;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dc-row-title:hover { text-decoration: underline; }
|
||||
.dc-row-nolink { color: var(--text); }
|
||||
.dc-row-detail {
|
||||
color: var(--text);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Meta is pushed right and allowed to disappear first: it is the least
|
||||
important part of the line, and letting it wrap is what made rows look
|
||||
like paragraphs. */
|
||||
.dc-row-meta {
|
||||
margin-left: auto;
|
||||
padding-left: 0.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
/* Shrinks and truncates before the title or detail do: it is the least
|
||||
important part of the line, and it was the part running off the edge. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* One chip per depleted supply. Bordered rather than filled: a row of solid
|
||||
red pills reads as an emergency even when a cartridge is merely low. */
|
||||
.dc-chip {
|
||||
flex: none;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
.dc-chip-critical { border-color: var(--danger); color: var(--danger); }
|
||||
.dc-chip-low { border-color: var(--warning); }
|
||||
|
||||
.dc-more {
|
||||
display: block;
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.dc-more-link { color: var(--link); text-decoration: none; }
|
||||
.dc-more-link:hover { text-decoration: underline; }
|
||||
|
||||
.dc-clear-line {
|
||||
margin: 0.25rem 0;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user