Ver Fonte

fix(s8): 九宫格展示异常灯芯与监控运行外圈

chore: bump version Web 2.4.415
Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX há 16 horas atrás
pai
commit
5f6600ecfe

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.414",
+	"version": "2.4.415",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 35 - 0
Web/src/views/aidop/api/kanbanData.ts

@@ -739,6 +739,41 @@ export interface S8HomeTrendPayload {
 	points: S8HomeTrendPoint[];
 }
 
+export interface S8ModuleLamp {
+	code: string
+	name: string
+	businessState: 'CRITICAL' | 'WARNING' | 'NORMAL' | 'UNKNOWN' | string
+	monitorState: 'HEALTHY' | 'DEGRADED' | 'PAUSED' | 'UNMONITORED' | string
+	seriousCount: number
+	followCount: number
+	openCount: number
+	configuredRuleCount: number
+	enabledRuleCount: number
+	runnableRuleCount: number
+	latestRunAt: string | null
+	monitorReasonCode: string
+}
+
+export interface S8ModuleLampPayload {
+	asOf: string
+	items: S8ModuleLamp[]
+}
+
+export async function fetchS8ModuleLamps(): Promise<S8ModuleLampPayload | null> {
+	try {
+		const res = await service.get('/api/AidopKanban/s8-module-lamps', {
+			headers: { 'X-Silent-Error': '1' },
+		})
+		const d = res.data ?? {}
+		return {
+			asOf: d.asOf || '',
+			items: Array.isArray(d.items) ? d.items : [],
+		}
+	} catch {
+		return null
+	}
+}
+
 export async function fetchS8HomeTrend(
 	factoryId = 1,
 	extra: KanbanExtraQuery = {}

+ 9 - 13
Web/src/views/aidop/kanban/factualDisplay.spec.ts

@@ -54,19 +54,15 @@ describe('S8 正式页面:不得出现无来源的事实数字', () => {
 
 	// ── P1-2 ──
 
-	it('HomeModuleStatus_IsNotHardcoded:状态灯不得写死健康等级', () => {
+	it('HomeModuleStatus_IsNotHardcoded:状态灯初始为未知,颜色只来自接口', () => {
 		const code = homePage();
-		const idx = code.indexOf('const s8ModuleStatus');
-		expect(idx).toBeGreaterThan(-1);
-		const block = code.slice(idx, code.indexOf(']', idx) + 1);
-
-		// 七个模块条目都在(导航功能保留)
-		for (const m of ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7']) expect(block).toContain(m);
-		// 但不得携带任何健康断言
-		for (const lvl of ["'critical'", "'warn'", "'orange'", "'ok'"]) {
-			expect(block).not.toContain(`level: ${lvl}`);
-		}
-		expect(block).toContain("level: 'unknown'");
+		expect(code).toContain("blankS8Lamps('LOADING')");
+		expect(code).toContain('fetchS8ModuleLamps');
+		expect(code).toContain("businessState: 'UNKNOWN'");
+		expect(code).not.toContain("businessState: 'NORMAL'");
+		expect(code).not.toContain("businessState: 'CRITICAL'");
+		expect(code).toContain("query: { stageCode: code }");
+		for (const m of ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7']) expect(code).toContain(`'${m}'`);
 	});
 
 	it('HomeAlerts_DoNotUseHardcodedRows:报警列表初始值必须为空', () => {
@@ -88,6 +84,6 @@ describe('S8 正式页面:不得出现无来源的事实数字', () => {
 		const code = homePage();
 		expect(code).toContain('fetchS8Alerts');
 		// 真实数据为空时置空数组,不得回落到任何本地假数据
-		expect(code).toMatch(/s8TopAlarmsAll\.value\s*=\s*alerts\?\.length\s*\?\s*alerts\s*:\s*\[\]/);
+		expect(code).toMatch(/s8TopAlarmsAll\.value\s*=\s*Array\.isArray\(alerts\)\s*\?\s*alerts\s*:\s*\[\]/);
 	});
 });

+ 3 - 7
Web/src/views/aidop/kanban/s8.vue

@@ -62,16 +62,12 @@ const alarms = computed(() => {
 			desc: s ? '需 1h 内响应' : '暂无数据(未取到预警汇总)',
 			level: s && s.critical > 0 ? 'danger' : 'ok',
 		},
-		// S8-P1-FACTUAL-DISPLAY-1:原为写死的 `list.length ? '86%' : '100%'`(副标题还写着「目标 95%」),
-		// 没有任何接口或表支撑,纯字面量冒充业务指标。
-		// 本页两张兄弟卡的数据源是 ado_s8_alert_record,而该表全仓没有任何状态/关闭字段——
-		// 告警本身就没有「闭环」这个状态机,闭环率在此不可算。
-		// 既有的 closureRate(S8DashboardService)建在另一张表 ado_s8_exception 上,
-		// 跨表挪用等于给本页自造一套业务语义,按「无 Authority 不得发明公式」一律不显示。
+		// 今日/区间计数已经改读 ado_s8_exception 的新建数。
+		// 闭环率仍不在本页现算:不把另一套 dashboard 公式搬过来冒充这张卡。
 		{
 			title: '闭环完成率',
 			value: '—',
-			desc: '暂无数据(告警表无闭环状态)',
+			desc: '暂无数据(本页不计算闭环率)',
 			level: 'ok',
 		},
 	];

+ 48 - 1
Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue

@@ -19,6 +19,11 @@
 					<el-option v-for="m in filterOpts.modules" :key="m.value" :label="m.label" :value="m.value" />
 				</el-select>
 			</el-form-item>
+			<el-form-item label="监控阶段">
+				<el-select v-model="query.stageCode" clearable placeholder="全部" style="width: 120px" @change="onStageChange">
+					<el-option v-for="code in STAGE_CODES" :key="code" :label="code" :value="code" />
+				</el-select>
+			</el-form-item>
 			<!-- ORDER-FLOW-S8-INTEGRATED-DOMAIN-RESET-1 t3j -->
 			<el-form-item label="订单号">
 				<el-input v-model="query.relatedObjectCode" placeholder="订单号 / 关联对象" clearable style="width: 180px" />
@@ -184,7 +189,7 @@
 </template>
 
 <script setup lang="ts" name="aidopS8ExceptionList">
-import { computed, onActivated, onMounted, reactive, ref } from 'vue';
+import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import { s8ExceptionApi, type S8ExceptionRow } from '../api/s8ExceptionApi';
@@ -228,6 +233,7 @@ const query = reactive({
 	severity: '' as string,
 	sceneCode: '' as string,
 	moduleCode: '' as string,
+	stageCode: '' as string,
 	deptId: undefined as number | undefined,
 	timeoutFlag: undefined as boolean | undefined,
 	ruleType: '' as string,
@@ -425,6 +431,7 @@ async function loadList() {
 			severity: query.severity || undefined,
 			sceneCode: query.sceneCode || undefined,
 			moduleCode: query.moduleCode || undefined,
+			stageCode: query.stageCode || undefined,
 			deptId: query.deptId || undefined,
 			keyword: query.keyword || undefined,
 			timeoutFlag: query.timeoutFlag,
@@ -452,6 +459,12 @@ function resetQuery() {
 	query.severity = '';
 	query.sceneCode = '';
 	query.moduleCode = '';
+	query.stageCode = '';
+	if (route.query.stageCode) {
+		const next: Record<string, any> = { ...route.query };
+		delete next.stageCode;
+		router.replace({ query: next });
+	}
 	query.deptId = undefined;
 	query.timeoutFlag = undefined;
 	query.ruleType = '';
@@ -479,7 +492,29 @@ function onStatusChange() {
 	if (query.status) query.statusBucket = '';
 }
 
+const STAGE_CODES = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7'];
+
+function stageFromRoute(): string {
+	const raw = route.query.stageCode;
+	const code = (Array.isArray(raw) ? raw[0] : raw || '').trim().toUpperCase();
+	return STAGE_CODES.includes(code) ? code : '';
+}
+
+function syncStageFromRoute() {
+	query.stageCode = stageFromRoute();
+}
+
+function onStageChange() {
+	const next: Record<string, any> = { ...route.query };
+	if (query.stageCode) next.stageCode = query.stageCode;
+	else delete next.stageCode;
+	router.replace({ query: next });
+	query.page = 1;
+	void loadList();
+}
+
 onMounted(async () => {
+	syncStageFromRoute();
 	if (route.query.status) query.status = String(route.query.status);
 	if (route.query.bucket) query.statusBucket = String(route.query.bucket);
 	if (route.query.timeout === '1' || route.query.timeoutFlag === 'true') query.timeoutFlag = true;
@@ -509,8 +544,20 @@ onActivated(async () => {
 		activatedOnce.value = true;
 		return;
 	}
+	syncStageFromRoute();
 	await loadList();
 });
+
+watch(
+	() => route.query.stageCode,
+	async () => {
+		const next = stageFromRoute();
+		if (next === query.stageCode) return;
+		query.stageCode = next;
+		query.page = 1;
+		await loadList();
+	}
+);
 </script>
 
 <style scoped>

+ 64 - 0
Web/src/views/dashboard/components/homeKpiPresentation.ts

@@ -96,3 +96,67 @@ export function s8TrendNote(points: Array<{ count?: number }> | null): string {
 	if (points.length > 0 && points.every((p) => Number(p?.count || 0) === 0)) return '近 7 日无异常'
 	return ''
 }
+
+const S8_BUSINESS_TEXT: Record<string, string> = {
+	CRITICAL: '有严重异常',
+	WARNING: '有关注异常',
+	NORMAL: '无未关闭异常',
+	UNKNOWN: '尚不能判断业务是否正常',
+}
+
+const S8_MONITOR_TEXT: Record<string, string> = {
+	HEALTHY: '监控运行正常',
+	DEGRADED: '监控运行异常',
+	PAUSED: '监控已暂停',
+	UNMONITORED: '未配置监控规则',
+}
+
+const S8_REASON_TEXT: Record<string, string> = {
+	OK: '',
+	NO_RULE: '没有监控规则',
+	RULE_PAUSED: '规则已暂停',
+	RULE_DISABLED: '规则已停用',
+	NEVER_RUN: '规则尚未运行',
+	RUN_FAILED: '最近一次运行失败',
+	RUN_STALE: '运行结果已过期',
+	LOADING: '监控状态加载中',
+	LOAD_FAILED: '监控状态加载失败',
+}
+
+export function s8LampCoreClass(businessState: string): string {
+	if (businessState === 'CRITICAL') return 'critical'
+	if (businessState === 'WARNING') return 'warn'
+	if (businessState === 'NORMAL') return 'ok'
+	return 'unknown'
+}
+
+export function s8LampTooltip(item: {
+	code?: string
+	name?: string
+	businessState?: string
+	monitorState?: string
+	seriousCount?: number
+	followCount?: number
+	configuredRuleCount?: number
+	runnableRuleCount?: number
+	latestRunAt?: string | null
+	monitorReasonCode?: string
+}): string {
+	const reasonCode = String(item.monitorReasonCode || '')
+	const business = reasonCode === 'LOADING' || reasonCode === 'LOAD_FAILED'
+		? S8_REASON_TEXT[reasonCode]
+		: S8_BUSINESS_TEXT[String(item.businessState || '')] || S8_BUSINESS_TEXT.UNKNOWN
+	const monitor = reasonCode === 'LOADING' || reasonCode === 'LOAD_FAILED'
+		? ''
+		: S8_MONITOR_TEXT[String(item.monitorState || '')] || ''
+	const reason = S8_REASON_TEXT[reasonCode] || ''
+	return [
+		`${item.code || ''} ${item.name || ''}`.trim(),
+		business,
+		`严重 ${Number(item.seriousCount || 0)} / 关注 ${Number(item.followCount || 0)}`,
+		monitor,
+		reason && reason !== business ? reason : '',
+		`规则 ${Number(item.configuredRuleCount || 0)},可运行 ${Number(item.runnableRuleCount || 0)}`,
+		item.latestRunAt ? `最近运行 ${item.latestRunAt}` : '最近运行 —',
+	].filter((line) => String(line || '').trim()).join('\n')
+}

+ 77 - 1
Web/src/views/dashboard/home.s8-empty.spec.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { readFileSync } from 'node:fs'
 import { resolve } from 'node:path'
-import { s8AlarmMessage, s8TrendNote } from './components/homeKpiPresentation'
+import { s8AlarmMessage, s8LampCoreClass, s8LampTooltip, s8TrendNote } from './components/homeKpiPresentation'
 
 describe('S8 home empty states', () => {
 	it('says there are no alarms when the list is empty', () => {
@@ -18,6 +18,82 @@ describe('S8 home empty states', () => {
 		expect(s8TrendNote(null)).toBe('异常数据加载失败')
 	})
 
+	it('maps only a healthy zero-exception module to green', () => {
+		expect(s8LampCoreClass('CRITICAL')).toBe('critical')
+		expect(s8LampCoreClass('WARNING')).toBe('warn')
+		expect(s8LampCoreClass('NORMAL')).toBe('ok')
+		expect(s8LampCoreClass('UNKNOWN')).toBe('unknown')
+	})
+
+	it('explains a red lamp whose monitor is paused', () => {
+		const text = s8LampTooltip({
+			code: 'S4',
+			name: '采购执行',
+			businessState: 'CRITICAL',
+			monitorState: 'PAUSED',
+			seriousCount: 29,
+			followCount: 0,
+			configuredRuleCount: 1,
+			runnableRuleCount: 0,
+			monitorReasonCode: 'RULE_DISABLED',
+		})
+		expect(text).toContain('有严重异常')
+		expect(text).toContain('监控已暂停')
+		expect(text).toContain('规则已停用')
+		expect(text).toContain('严重 29 / 关注 0')
+		expect(text).not.toContain('无未关闭异常')
+	})
+
+	it('includes the latest run time in the lamp tooltip', () => {
+		const text = s8LampTooltip({
+			code: 'S1',
+			businessState: 'WARNING',
+			monitorState: 'HEALTHY',
+			seriousCount: 0,
+			followCount: 2,
+			latestRunAt: '2026-09-30 00:30:00',
+			monitorReasonCode: 'OK',
+		})
+		expect(text).toContain('最近运行 2026-09-30 00:30:00')
+		expect(text).toContain('有关注异常')
+	})
+
+	it('does not call an unmonitored module normal', () => {
+		const text = s8LampTooltip({
+			code: 'S3',
+			businessState: 'UNKNOWN',
+			monitorState: 'UNMONITORED',
+			monitorReasonCode: 'NO_RULE',
+		})
+		expect(text).toContain('未配置监控规则')
+		expect(text).not.toContain('无未关闭异常')
+	})
+
+	it('says the lamps are still loading instead of showing a result', () => {
+		expect(s8LampTooltip({ code: 'S1', businessState: 'UNKNOWN', monitorReasonCode: 'LOADING' })).toContain('加载中')
+		expect(s8LampTooltip({ code: 'S1', monitorReasonCode: 'LOAD_FAILED' })).toContain('加载失败')
+	})
+
+	it('drills a lamp into the exception list for that stage', () => {
+		const home = readFileSync(resolve(__dirname, './home.vue'), 'utf8')
+			.replace(/\/\*[\s\S]*?\*\//g, '')
+			.replace(/^\s*\/\/.*$/gm, '')
+			.replace(/<!--[\s\S]*?-->/g, '')
+		const list = readFileSync(resolve(__dirname, '../aidop/s8/exceptions/S8ExceptionListPage.vue'), 'utf8')
+			.replace(/\/\*[\s\S]*?\*\//g, '')
+			.replace(/^\s*\/\/.*$/gm, '')
+			.replace(/<!--[\s\S]*?-->/g, '')
+		expect(home).toContain("['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7']")
+		expect(home).toContain("blankS8Lamps('LOAD_FAILED')")
+		expect(home).not.toContain("businessState: 'NORMAL'")
+		expect(home).toContain("path: '/aidop/s8/exceptions'")
+		expect(home).toContain('stageCode: code')
+		expect(list).toContain('stageCode: query.stageCode')
+		expect(list).toContain('syncStageFromRoute')
+		expect(list).toContain('route.query.stageCode')
+		expect(list).toContain('监控阶段')
+	})
+
 	it('does not restore the deleted demo alarms', () => {
 		const raw = readFileSync(resolve(__dirname, './home.vue'), 'utf8')
 		const home = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/<!--[\s\S]*?-->/g, '')

+ 96 - 28
Web/src/views/dashboard/home.vue

@@ -136,11 +136,14 @@
                 :key="m.code"
                 type="button"
                 class="s8-mod"
-                :title="m.level === 'unknown' ? `${m.code} 未监测(暂无模块健康数据)` : m.code"
-                @click.stop="navigateTo(m.route)"
+                :title="s8LampTooltip(m)"
+                @click.stop="openS8Exceptions(m.code)"
               >
-                <span class="s8-mod__dot" :class="`s8-mod__dot--${m.level}`" />
-                <span class="s8-mod__label" :class="`s8-mod__label--${m.level}`">{{ m.code }}</span>
+                <span class="s8-mod__dot" :class="[`s8-mod__dot--${s8LampCoreClass(m.businessState)}`, `s8-mod__ring--${(m.monitorState || 'unmonitored').toLowerCase()}`]">
+                  <span v-if="m.monitorState === 'PAUSED'" class="s8-mod__badge">停</span>
+                  <span v-else-if="m.monitorState === 'DEGRADED'" class="s8-mod__badge">!</span>
+                </span>
+                <span class="s8-mod__label" :class="`s8-mod__label--${s8LampCoreClass(m.businessState)}`">{{ m.code }}</span>
               </button>
             </div>
           </div>
@@ -219,7 +222,8 @@ import {
   User
 } from '@element-plus/icons-vue'
 import * as echarts from 'echarts'
-import { fetchS8Alerts, fetchS8HomeTrend } from '../aidop/api/kanbanData'
+import { fetchS8Alerts, fetchS8HomeTrend, fetchS8ModuleLamps } from '../aidop/api/kanbanData'
+import { s8AlarmMessage, s8TrendNote, s8LampTooltip, s8LampCoreClass } from './components/homeKpiPresentation'
 import DetailQueryBar from '../aidop/kanban/components/DetailQueryBar.vue'
 import SmartOpsBaseQueryFields from '../aidop/kanban/components/SmartOpsBaseQueryFields.vue'
 import {
@@ -229,7 +233,6 @@ import {
   summarizeSmartOpsBaseQuery,
 } from '../aidop/kanban/utils/smartOpsBaseQuery'
 import HomeModuleKpiCard from './components/HomeModuleKpiCard.vue'
-import { s8AlarmMessage, s8TrendNote } from './components/homeKpiPresentation'
 import ChatBIFloat from '../aidop/chatbi/ChatBIFloat.vue'
 import DashboardFullscreenButton from '../aidop/kanban/components/DashboardFullscreenButton.vue'
 
@@ -257,26 +260,26 @@ function tickHeaderClock() {
   headerDateTime.value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
 }
 
-/**
- * S1–S7 模块入口。
- *
- * S8-P1-FACTUAL-DISPLAY-1:原先每项带一个写死的 level(S2=critical / S4=warn / S6=orange),
- * 注释是「与参考图一致」——即直接从效果图抄来的颜色,从不请求、从不更新。
- * 用户看到的是一组**永远不变、却长得像实时健康状态**的红黄灯。
- *
- * 后端当前没有任何模块健康 Authority(全仓无 module health / system status 服务),
- * 因此不再给出健康断言:这些按钮只保留它们真正具备的功能——跳转到对应模块页。
- * 状态语义统一落 'unknown'(中性灰 + 「未监测」提示),等真实健康源就位后再接。
- */
-const s8ModuleStatus = [
-  { code: 'S1', level: 'unknown', route: 's1' },
-  { code: 'S2', level: 'unknown', route: 's2' },
-  { code: 'S3', level: 'unknown', route: 's3' },
-  { code: 'S4', level: 'unknown', route: 's4' },
-  { code: 'S5', level: 'unknown', route: 's5' },
-  { code: 'S6', level: 'unknown', route: 's6' },
-  { code: 'S7', level: 'unknown', route: 's7' }
-]
+const S8_STAGE_CODES = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7']
+
+function blankS8Lamps(reason) {
+  return S8_STAGE_CODES.map((code) => ({
+    code,
+    name: '',
+    businessState: 'UNKNOWN',
+    monitorState: 'UNMONITORED',
+    seriousCount: 0,
+    followCount: 0,
+    openCount: 0,
+    configuredRuleCount: 0,
+    enabledRuleCount: 0,
+    runnableRuleCount: 0,
+    latestRunAt: null,
+    monitorReasonCode: reason
+  }))
+}
+
+const s8ModuleStatus = ref(blankS8Lamps('LOADING'))
 
 /**
  * TOP 报警列表。真实数据由 loadS8Data() → fetchS8Alerts 覆盖(空/异常时置 [])。
@@ -315,8 +318,29 @@ function onDashboardReset() {
  * moduleCode + kpiExtraParams 拉取和响应 AIDOP_LAYOUT_SAVED 事件;
  * 本页仅负责 S8 的报警列表。
  */
+async function loadS8ModuleLamps() {
+  try {
+    const payload = await fetchS8ModuleLamps()
+    const items = Array.isArray(payload?.items) ? payload.items : null
+    if (!items) {
+      s8ModuleStatus.value = blankS8Lamps('LOAD_FAILED')
+      return
+    }
+    const byCode = new Map(items.map((item) => [item.code, item]))
+    const missing = blankS8Lamps('NO_RULE')
+    s8ModuleStatus.value = S8_STAGE_CODES.map((code, index) => byCode.get(code) || missing[index])
+  } catch {
+    s8ModuleStatus.value = blankS8Lamps('LOAD_FAILED')
+  }
+}
+
+function openS8Exceptions(code) {
+  router.push({ path: '/aidop/s8/exceptions', query: { stageCode: code } })
+}
+
 async function loadS8Data() {
   const extra = baseQueryToApiParams(dashboardBaseQuery.value)
+  const lamps = loadS8ModuleLamps()
   try {
     const alerts = await fetchS8Alerts(1, extra)
     s8TopAlarmsAll.value = Array.isArray(alerts) ? alerts : []
@@ -325,7 +349,7 @@ async function loadS8Data() {
     s8TopAlarmsAll.value = []
     s8AlarmState.value = 'error'
   }
-  await renderS8Trend(extra)
+  await Promise.all([lamps, renderS8Trend(extra)])
 }
 
 let chartS8 = null
@@ -1077,6 +1101,7 @@ onUnmounted(() => {
 }
 
 .s8-mod__dot {
+  position: relative;
   width: 14px;
   height: 14px;
   border-radius: 50%;
@@ -1109,7 +1134,50 @@ onUnmounted(() => {
    刻意不发光、不取红黄绿任一色——它表示「未监测」,不是「正常」。 */
 .s8-mod__dot--unknown {
   background: #475569;
-  box-shadow: 0 0 0 2px rgba(71, 85, 105, 0.35);
+  box-shadow: none;
+}
+
+.s8-mod__ring--healthy.s8-mod__dot--ok {
+  box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25);
+}
+
+.s8-mod__ring--healthy.s8-mod__dot--critical {
+  box-shadow:
+    0 0 10px 3px rgba(239, 68, 68, 0.85),
+    0 0 20px 6px rgba(239, 68, 68, 0.45);
+}
+
+.s8-mod__ring--healthy.s8-mod__dot--warn {
+  box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.35);
+}
+
+.s8-mod__ring--degraded {
+  outline: 2px dashed #f97316;
+  outline-offset: 2px;
+}
+
+.s8-mod__ring--paused {
+  outline: 2px dashed #94a3b8;
+  outline-offset: 2px;
+}
+
+.s8-mod__ring--unmonitored {
+  box-shadow: none;
+}
+
+.s8-mod__badge {
+  position: absolute;
+  right: -6px;
+  top: -6px;
+  min-width: 12px;
+  height: 12px;
+  padding: 0 2px;
+  border-radius: 6px;
+  background: #0f172a;
+  color: #e2e8f0;
+  font-size: 9px;
+  line-height: 12px;
+  text-align: center;
 }
 
 .s8-mod__label {