增加设备运行报表

main
liutao 1 week ago
parent bd3d30608b
commit 3f33f3f0dd

@ -81,7 +81,8 @@ const whiteList = [
'/register',
'/oauthLogin/gitee',
'/iot/report/dashboardPage/Dashboard8',
'/iot/report/dashboardPage/Dashboard1'
'/iot/report/dashboardPage/Dashboard1',
'/iot/report/dashboardPage/Dashboard3'
]
// 路由加载前

@ -653,6 +653,17 @@ const remainingRouter: AppRouteRecordRaw[] = [
canTo: true
}
},
{
path: '/iot/report/dashboardPage/Dashboard3',
component: () => import('@/views/report/dashboardPage/dashboard3/index.vue'),
name: 'IotReportDashboard3',
meta: {
title: '设备运行总览大屏',
hidden: true,
noTagsView: true,
canTo: true
}
},
{
path: '/iot',
component: Layout,

@ -185,6 +185,7 @@ const total = ref(0)
const getDashboardImage = (item: DashboardItem) => {
if (item.type === '2') return dashboardImage1
if (item.type === '1') return dashboardImage2
if (item.type === '3') return dashboardImage1
return item.indexImage || defaultImage
}
@ -373,6 +374,7 @@ const openEditDialog = (item: DashboardItem) => {
const getRouteByType = (type?: string) => {
if (type === '1') return 'iot/report/dashboardPage/Dashboard1'
if (type === '2') return 'iot/report/dashboardPage/Dashboard8'
if (type === '3') return 'iot/report/dashboardPage/Dashboard3'
return ''
}

@ -0,0 +1,937 @@
<template>
<div ref="dashboardRef" class="run-dashboard">
<div class="bg-grid"></div>
<header class="dashboard-header">
<div class="brand">
<div class="brand-icon">
<Icon icon="ep:cpu" />
</div>
<div class="brand-text">IOT 实时监控中心副标题</div>
</div>
<h1>设备运行总览大屏标题</h1>
<div class="header-meta">
<div><span>当前时间</span>{{ currentTime }}</div>
<div><span>最后更新</span>{{ lastUpdateTime || '-' }}</div>
<div class="refresh-row">
<span>刷新频率</span>15 秒刷新
<button type="button" class="screen-btn" @click="toggleFullscreen">
<Icon :icon="isFullscreen ? 'zmdi:fullscreen-exit' : 'zmdi:fullscreen'" />
</button>
</div>
</div>
</header>
<main class="dashboard-main">
<section class="top-row">
<div
v-for="card in statusCards"
:key="card.key"
class="metric-card"
:class="`metric-card--${card.tone}`"
>
<div class="metric-title">
<Icon :icon="card.icon" />
<span>{{ card.label }}</span>
</div>
<div class="metric-value">
<strong>{{ card.value }}</strong>
<span v-if="card.percent !== undefined">{{ formatPercent(card.percent) }}</span>
</div>
</div>
<div class="alarm-board panel">
<div class="alarm-head">
<span>当前报警设备</span>
<span>今日累计报警时长</span>
<span>最后上报时间</span>
</div>
<div v-if="alarmDevices.length" class="alarm-list">
<div v-for="item in alarmDevices" :key="item.id" class="alarm-item">
<strong :title="item.name">{{ item.name }}</strong>
<span>报警 {{ formatMinutes(item.alarmMinutes) }}</span>
<span>{{ item.lastReportTime }}</span>
</div>
</div>
<div v-else class="empty-line">暂无报警设备</div>
</div>
</section>
<section class="panel detail-panel">
<div class="panel-title">
<h2>设备运行明细</h2>
<span>{{ detailPageText }} · 自动轮播</span>
</div>
<div class="detail-table">
<div class="table-header table-row">
<span>设备名称</span>
<span>当前状态</span>
<span>运行时间(今日)</span>
<span>空闲时间(今日)</span>
<span>报警时间(今日)</span>
<span>运行效率(今日)</span>
</div>
<div v-if="pagedRows.length" class="table-body">
<div
v-for="row in pagedRows"
:key="row.id"
class="table-row"
:class="`table-row--${row.status}`"
>
<strong :title="row.name">{{ row.name }}</strong>
<span>
<em class="status-pill" :class="`status-pill--${row.status}`">{{ statusText[row.status] }}</em>
</span>
<span>{{ formatMinutes(row.runningMinutes) }}</span>
<span>{{ formatMinutes(row.standbyMinutes) }}</span>
<span>{{ formatMinutes(row.alarmMinutes) }}</span>
<span class="rate">{{ formatPercent(row.utilizationRate) }}</span>
</div>
</div>
<div v-else class="empty-table">暂无设备运行数据</div>
</div>
</section>
<section class="chart-row">
<div class="panel chart-panel">
<div class="panel-title">
<h2> 24 小时运行率趋势</h2>
<span>平均运行率{{ formatPercent(todayAverageRate) }}</span>
</div>
<div ref="hourChartRef" class="chart"></div>
</div>
<div class="panel chart-panel">
<div class="panel-title">
<h2> 7 天运行率趋势</h2>
<span>平均运行率{{ formatPercent(sevenDayAverageRate) }}</span>
</div>
<div ref="weekChartRef" class="chart"></div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { useFullscreen } from '@vueuse/core'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { DeviceOperationOverviewApi } from '@/api/iot/deviceOperationOverview'
import type { DeviceOperationOverviewRespVO, DeviceOperationOverviewTimelineRowVO } from '@/api/iot/deviceOperationOverview'
import { OrganizationApi } from '@/api/mes/organization'
defineOptions({ name: 'IotReportDashboard3' })
type RunStatus = 'running' | 'standby' | 'fault' | 'offline'
interface OrganizationFilterItem {
id: string
parentId?: string | null
dvId?: number | string | null
machineName?: string
}
interface DetailRow {
id: string
name: string
status: RunStatus
runningMinutes: number
standbyMinutes: number
alarmMinutes: number
utilizationRate: number
lastReportTime: string
}
const route = useRoute()
const dashboardRef = ref<HTMLElement>()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(dashboardRef)
const currentTime = ref('')
const lastUpdateTime = ref('')
const overviewData = ref<DeviceOperationOverviewRespVO>({
metrics: [],
hourlyStatus: [],
summary: [],
summaryTotalHours: 0,
timelineRows: [],
totalDevices: 0
})
const weekTrend = ref<{ date: string; rate: number }[]>([])
const detailPage = ref(1)
const detailPageSize = 8
const organizationList = ref<OrganizationFilterItem[]>([])
const hourChartRef = ref<HTMLElement | null>(null)
const weekChartRef = ref<HTMLElement | null>(null)
let hourChart: echarts.ECharts | null = null
let weekChart: echarts.ECharts | null = null
let clockTimer: number | undefined
let refreshTimer: number | undefined
let carouselTimer: number | undefined
const statusText: Record<RunStatus, string> = {
running: '运行',
standby: '空闲',
fault: '报警',
offline: '离线'
}
const metricValueMap = computed(() => {
return overviewData.value.metrics.reduce<Record<string, number>>((acc, item) => {
acc[item.key] = Number(item.value) || 0
return acc
}, {})
})
const getMetricValue = (keys: string[]) => {
const foundKey = keys.find((key) => metricValueMap.value[key] !== undefined)
return foundKey ? metricValueMap.value[foundKey] : undefined
}
const summaryMap = computed(() => {
return overviewData.value.summary.reduce<Record<string, number>>((acc, item) => {
acc[item.status] = Number(item.percent) || 0
return acc
}, {})
})
const statusCards = computed(() => [
{
key: 'total',
label: '设备总数',
icon: 'ep:connection',
value:
overviewData.value.totalDevices ||
getMetricValue(['total', 'deviceTotal', 'totalDevices']) ||
overviewData.value.timelineRows.length,
tone: 'total'
},
{
key: 'online',
label: '在线',
icon: 'ep:cpu',
value: Math.max(0, totalDeviceCount.value - offlineDeviceCount.value),
percent: 100 - (summaryMap.value.offline || 0),
tone: 'online'
},
{
key: 'offline',
label: '离线',
icon: 'ep:link',
value: offlineDeviceCount.value,
percent: summaryMap.value.offline || 0,
tone: 'offline'
},
{
key: 'running',
label: '运行',
icon: 'ep:video-play',
value: getMetricValue(['running', 'run', 'runningDevices']) ?? getRowsByStatus('running').length,
percent: summaryMap.value.running || 0,
tone: 'running'
},
{
key: 'standby',
label: '空闲',
icon: 'ep:video-pause',
value: getMetricValue(['standby', 'idle', 'standbyDevices']) ?? getRowsByStatus('standby').length,
percent: summaryMap.value.standby || 0,
tone: 'standby'
},
{
key: 'fault',
label: '报警',
icon: 'ep:bell',
value: getMetricValue(['fault', 'alarm', 'faultDevices']) ?? getRowsByStatus('fault').length,
percent: summaryMap.value.fault || 0,
tone: 'fault'
}
])
const detailRows = computed<DetailRow[]>(() => {
return overviewData.value.timelineRows.map((row) => {
const minutes = calcMinutes(row)
const status = getCurrentStatus(row)
return {
id: String(row.id),
name: row.name || '-',
status,
runningMinutes: minutes.running,
standbyMinutes: minutes.standby,
alarmMinutes: minutes.fault,
utilizationRate: Number(row.utilizationRate) || 0,
lastReportTime: status === 'fault' || minutes.fault > 0 ? lastUpdateTime.value || '-' : '-'
}
})
})
const totalDetailPages = computed(() => Math.max(1, Math.ceil(detailRows.value.length / detailPageSize)))
const totalDeviceCount = computed(
() =>
overviewData.value.totalDevices ||
getMetricValue(['total', 'deviceTotal', 'totalDevices']) ||
detailRows.value.length
)
const offlineDeviceCount = computed(
() => getMetricValue(['offline', 'offlineDevices']) ?? getRowsByStatus('offline').length
)
const pagedRows = computed(() => {
const start = (detailPage.value - 1) * detailPageSize
return detailRows.value.slice(start, start + detailPageSize)
})
const detailPageText = computed(() => `${detailPage.value} / ${totalDetailPages.value}`)
const alarmDevices = computed(() => {
return detailRows.value
.filter((item) => item.status === 'fault' || item.alarmMinutes > 0)
.sort((a, b) => b.alarmMinutes - a.alarmMinutes)
.slice(0, 2)
})
const hourlyRates = computed(() =>
overviewData.value.hourlyStatus.map((item) => {
const total = item.running + item.standby + item.fault + item.offline
return {
hour: item.hour,
rate: total > 0 ? Number(((item.running / total) * 100).toFixed(1)) : 0
}
})
)
const todayAverageRate = computed(() => average(hourlyRates.value.map((item) => item.rate)))
const sevenDayAverageRate = computed(() => average(weekTrend.value.map((item) => item.rate)))
const formatPercent = (value?: number) => `${Number(value || 0).toFixed(1)}%`
const formatMinutes = (minutes: number) => {
const safeMinutes = Math.max(0, Math.round(minutes || 0))
const hours = Math.floor(safeMinutes / 60)
const rest = safeMinutes % 60
if (!hours) return `${rest}`
if (!rest) return `${hours}小时`
return `${hours}小时${rest}`
}
const average = (values: number[]) => {
const valid = values.filter((item) => Number.isFinite(item))
if (!valid.length) return 0
return Number((valid.reduce((sum, item) => sum + item, 0) / valid.length).toFixed(1))
}
const getRowsByStatus = (status: RunStatus) => detailRows.value.filter((row) => row.status === status)
const getCurrentStatus = (row: DeviceOperationOverviewTimelineRowVO): RunStatus => {
const currentHour = dayjs().hour() + dayjs().minute() / 60
const currentSegment = row.segments?.find((item) => currentHour >= item.startHour && currentHour < item.endHour)
return currentSegment?.status || row.segments?.[row.segments.length - 1]?.status || 'offline'
}
const calcMinutes = (row: DeviceOperationOverviewTimelineRowVO) => {
return (row.segments || []).reduce<Record<RunStatus, number>>(
(acc, segment) => {
const minutes = Math.max(0, (Number(segment.endHour) - Number(segment.startHour)) * 60)
acc[segment.status] += minutes
return acc
},
{ running: 0, standby: 0, fault: 0, offline: 0 }
)
}
const updateClock = () => {
currentTime.value = dayjs().format('YYYY/M/D HH:mm:ss')
}
const childrenByParentId = computed(() => {
return organizationList.value.reduce<Record<string, OrganizationFilterItem[]>>((acc, item) => {
const parentKey = String(item.parentId ?? 0)
if (!acc[parentKey]) acc[parentKey] = []
acc[parentKey].push(item)
return acc
}, {})
})
const collectDeviceIdsByOrg = (orgId?: string) => {
const deviceIds = new Set<string>()
const pushDevice = (node?: OrganizationFilterItem) => {
if (node?.dvId) deviceIds.add(String(node.dvId))
}
if (!orgId) {
organizationList.value.forEach(pushDevice)
return Array.from(deviceIds)
}
const queue = [orgId]
while (queue.length) {
const currentId = queue.shift() as string
const current = organizationList.value.find((item) => item.id === currentId)
pushDevice(current)
;(childrenByParentId.value[currentId] || []).forEach((child) => queue.push(child.id))
}
return Array.from(deviceIds)
}
const getDeviceIds = () => {
const routeIds = String(route.query.ids || route.query.deviceIds || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
if (routeIds.length) return routeIds
return collectDeviceIdsByOrg(route.query.orgId ? String(route.query.orgId) : undefined)
}
const buildParams = (startTime: string, endTime: string, pageSize = 1000) => ({
ids: getDeviceIds().join(','),
startTime,
endTime,
timelinePageNo: 1,
timelinePageSize: pageSize
})
const loadOrganizationList = async () => {
const data = await OrganizationApi.getOrganizationList()
organizationList.value = Array.isArray(data)
? data.map((item: any) => ({
id: String(item.id),
parentId: item.parentId != null ? String(item.parentId) : item.parentId,
dvId: item.dvId,
machineName: item.machineName
}))
: []
}
const refreshTodayData = async () => {
const start = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss')
const end = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss')
const response = await DeviceOperationOverviewApi.getRunOverview(buildParams(start, end))
overviewData.value = {
metrics: response?.metrics || [],
hourlyStatus: response?.hourlyStatus || [],
summary: response?.summary || [],
summaryTotalHours: response?.summaryTotalHours || 0,
timelineRows: response?.timelineRows || [],
totalDevices: response?.totalDevices || 0
}
lastUpdateTime.value = dayjs().format('YYYY/M/D HH:mm:ss')
}
const refreshWeekTrend = async () => {
const requests = Array.from({ length: 7 }).map((_, index) => {
const date = dayjs().subtract(6 - index, 'day')
return DeviceOperationOverviewApi.getRunOverview(
buildParams(date.startOf('day').format('YYYY-MM-DD HH:mm:ss'), date.endOf('day').format('YYYY-MM-DD HH:mm:ss'), 1)
).then((res) => {
const running = res?.summary?.find((item) => item.status === 'running')?.percent || 0
return {
date: date.format('MM-DD'),
rate: Number(Number(running).toFixed(1))
}
})
})
weekTrend.value = await Promise.all(requests)
}
const refreshData = async () => {
await refreshTodayData()
await refreshWeekTrend()
renderCharts()
}
const getLineChartOption = (labels: string[], values: number[]): EChartsOption => ({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
valueFormatter: (value) => `${value}%`
},
grid: { left: 42, right: 24, top: 26, bottom: 32 },
xAxis: {
type: 'category',
boundaryGap: false,
data: labels,
axisLine: { lineStyle: { color: 'rgba(105, 165, 235, 0.55)' } },
axisTick: { show: false },
axisLabel: { color: '#a9c8ef', fontSize: 12 }
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { color: '#a9c8ef', formatter: '{value}%' },
splitLine: { lineStyle: { color: 'rgba(76, 144, 219, 0.18)' } }
},
series: [
{
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 5,
data: values,
lineStyle: { width: 3, color: '#4692ff' },
itemStyle: { color: '#54a7ff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(70, 146, 255, 0.46)' },
{ offset: 1, color: 'rgba(70, 146, 255, 0.04)' }
])
}
}
]
})
const renderCharts = () => {
if (hourChartRef.value && !hourChart) hourChart = echarts.init(hourChartRef.value, 'dark', { renderer: 'canvas' })
if (weekChartRef.value && !weekChart) weekChart = echarts.init(weekChartRef.value, 'dark', { renderer: 'canvas' })
hourChart?.setOption(
getLineChartOption(
hourlyRates.value.map((item) => item.hour),
hourlyRates.value.map((item) => item.rate)
)
)
weekChart?.setOption(
getLineChartOption(
weekTrend.value.map((item) => item.date),
weekTrend.value.map((item) => item.rate)
)
)
}
const resizeCharts = () => {
hourChart?.resize()
weekChart?.resize()
}
onMounted(async () => {
updateClock()
clockTimer = window.setInterval(updateClock, 1000)
await loadOrganizationList()
await refreshData()
refreshTimer = window.setInterval(() => {
void refreshData()
}, 15000)
carouselTimer = window.setInterval(() => {
detailPage.value = detailPage.value >= totalDetailPages.value ? 1 : detailPage.value + 1
}, 5000)
window.addEventListener('resize', resizeCharts)
})
onUnmounted(() => {
if (clockTimer) window.clearInterval(clockTimer)
if (refreshTimer) window.clearInterval(refreshTimer)
if (carouselTimer) window.clearInterval(carouselTimer)
window.removeEventListener('resize', resizeCharts)
hourChart?.dispose()
weekChart?.dispose()
})
</script>
<style scoped>
.run-dashboard {
--bg: #03142d;
--panel: rgb(10 44 88 / 82%);
--panel-deep: rgb(6 27 61 / 88%);
--line: rgb(47 129 222 / 62%);
--text: #eef6ff;
--muted: #91b7e5;
--blue: #49a0ff;
--green: #40f06d;
--red: #ff4d5b;
--gray: #aec0d7;
position: relative;
width: 100%;
min-width: 1366px;
min-height: 100vh;
overflow: hidden;
font-family: "Microsoft Yahei", "PingFang SC", Arial, sans-serif;
color: var(--text);
background:
radial-gradient(circle at 50% 0, rgb(21 88 157 / 38%), transparent 34%),
linear-gradient(180deg, #061a38 0%, #041329 48%, #031026 100%);
}
.bg-grid {
position: absolute;
pointer-events: none;
background-image:
linear-gradient(rgb(42 122 204 / 16%) 1px, transparent 1px),
linear-gradient(90deg, rgb(42 122 204 / 16%) 1px, transparent 1px);
background-size: 96px 40px;
inset: 0;
}
.dashboard-header {
position: relative;
z-index: 1;
display: grid;
height: 86px;
padding: 12px 24px 8px;
grid-template-columns: 360px 1fr 360px;
align-items: start;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
color: #c7dcf6;
font-size: 18px;
}
.brand-icon {
display: grid;
width: 34px;
height: 34px;
color: #8cd3ff;
background: rgb(78 157 255 / 16%);
border: 1px solid rgb(93 177 255 / 40%);
border-radius: 6px;
place-items: center;
}
.dashboard-header h1 {
margin: 0;
color: #fff;
font-size: 42px;
font-weight: 800;
line-height: 1.15;
text-align: center;
text-shadow: 0 0 12px rgb(105 178 255 / 86%);
letter-spacing: 0;
}
.header-meta {
justify-self: end;
color: #b6d0ef;
font-size: 14px;
line-height: 1.75;
}
.header-meta span {
color: #fff;
font-weight: 700;
}
.refresh-row {
display: flex;
align-items: center;
gap: 6px;
}
.screen-btn {
display: inline-grid;
width: 26px;
height: 26px;
padding: 0;
color: #8fc7ff;
cursor: pointer;
background: rgb(16 62 116 / 80%);
border: 1px solid rgb(77 151 232 / 70%);
border-radius: 4px;
place-items: center;
}
.dashboard-main {
position: relative;
z-index: 1;
display: flex;
height: calc(100vh - 86px);
min-height: 820px;
padding: 8px 24px 16px;
box-sizing: border-box;
flex-direction: column;
gap: 12px;
}
.top-row {
display: grid;
grid-template-columns: repeat(6, minmax(150px, 1fr)) 2fr;
gap: 10px;
}
.panel,
.metric-card {
background: linear-gradient(135deg, var(--panel), var(--panel-deep));
border: 1px solid var(--line);
border-radius: 5px;
box-shadow: inset 0 0 24px rgb(34 113 203 / 18%);
}
.metric-card {
height: 120px;
padding: 16px;
box-sizing: border-box;
}
.metric-title {
display: flex;
align-items: center;
gap: 12px;
color: #d7e9ff;
font-size: 16px;
}
.metric-title .iconify {
font-size: 22px;
}
.metric-value {
display: flex;
margin-top: 16px;
align-items: baseline;
gap: 18px;
}
.metric-value strong {
font-size: 40px;
line-height: 1;
}
.metric-value span {
color: #a8bfdd;
font-size: 16px;
font-weight: 700;
}
.metric-card--online .metric-value strong,
.metric-card--running .metric-value strong,
.metric-card--online .metric-title,
.metric-card--running .metric-title {
color: var(--green);
}
.metric-card--offline .metric-value strong,
.metric-card--offline .metric-title {
color: var(--gray);
}
.metric-card--standby .metric-value strong,
.metric-card--standby .metric-title {
color: var(--blue);
}
.metric-card--fault {
border-color: rgb(255 67 84 / 86%);
}
.metric-card--fault .metric-value strong,
.metric-card--fault .metric-title {
color: var(--red);
}
.alarm-board {
height: 120px;
padding: 12px;
box-sizing: border-box;
}
.alarm-head,
.alarm-item {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
align-items: center;
gap: 8px;
}
.alarm-head {
color: #fff;
font-size: 15px;
font-weight: 700;
}
.alarm-list {
display: flex;
margin-top: 8px;
flex-direction: column;
gap: 8px;
}
.alarm-item {
height: 30px;
padding: 0 8px;
overflow: hidden;
color: #e8f2ff;
background: linear-gradient(90deg, rgb(102 25 56 / 76%), rgb(33 31 69 / 52%));
border: 1px solid rgb(255 64 82 / 52%);
border-radius: 4px;
font-size: 13px;
}
.alarm-item strong {
overflow: hidden;
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
}
.alarm-item span:nth-child(2) {
color: #ff5365;
font-weight: 700;
text-align: center;
}
.alarm-item span:nth-child(3) {
color: #d7aab4;
text-align: right;
}
.empty-line,
.empty-table {
display: grid;
height: 100%;
color: var(--muted);
place-items: center;
}
.detail-panel {
min-height: 0;
padding: 14px 16px;
flex: 1.25;
}
.panel-title {
display: flex;
height: 30px;
align-items: center;
justify-content: space-between;
}
.panel-title h2 {
margin: 0;
color: #fff;
font-size: 22px;
font-weight: 800;
}
.panel-title span {
color: #89bdf5;
font-size: 14px;
}
.detail-table {
display: flex;
height: calc(100% - 34px);
min-height: 0;
flex-direction: column;
}
.table-row {
display: grid;
grid-template-columns: 2fr 1fr 1.4fr 1.4fr 1.4fr 1.4fr;
min-height: 43px;
padding: 0 14px;
align-items: center;
gap: 12px;
border-bottom: 1px solid rgb(60 134 212 / 35%);
}
.table-header {
min-height: 44px;
color: #98bee8;
font-size: 14px;
}
.table-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.table-body .table-row {
color: #dcecff;
background: rgb(12 52 98 / 42%);
border: 1px solid rgb(60 134 212 / 48%);
border-radius: 5px;
font-size: 16px;
}
.table-row strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-row--fault {
color: #ff5768;
background: linear-gradient(90deg, rgb(108 27 58 / 82%), rgb(11 39 83 / 46%)) !important;
border-color: rgb(255 64 82 / 80%) !important;
}
.table-row--running strong,
.table-row--running .rate {
color: var(--green);
}
.table-row--offline strong {
color: #96adca;
}
.rate {
color: #54a7ff;
font-weight: 800;
}
.status-pill {
display: inline-flex;
min-width: 42px;
height: 24px;
padding: 0 10px;
font-style: normal;
border: 1px solid currentcolor;
border-radius: 13px;
align-items: center;
justify-content: center;
}
.status-pill--running {
color: var(--green);
background: rgb(34 197 94 / 12%);
}
.status-pill--standby {
color: var(--blue);
background: rgb(59 130 246 / 12%);
}
.status-pill--fault {
color: var(--red);
background: rgb(239 68 68 / 14%);
}
.status-pill--offline {
color: #b5c4d9;
background: rgb(148 163 184 / 14%);
}
.chart-row {
display: grid;
min-height: 280px;
flex: 0.9;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.chart-panel {
min-height: 0;
padding: 14px 16px;
}
.chart {
width: 100%;
height: calc(100% - 34px);
min-height: 220px;
}
@media (width <= 1600px) {
.dashboard-header h1 {
font-size: 34px;
}
.dashboard-header {
grid-template-columns: 310px 1fr 320px;
}
.metric-card {
padding: 14px 12px;
}
.metric-value strong {
font-size: 34px;
}
}
</style>
Loading…
Cancel
Save