arkaos 4.13.1 → 4.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION +1 -1
- package/bin/arka-doctor +15 -1
- package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
- package/core/governance/evidence_checks.py +85 -4
- package/core/workflow/__pycache__/design_authorization.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
- package/core/workflow/design_authorization.py +106 -0
- package/core/workflow/frontend_gate.py +54 -22
- package/dashboard/app/app.config.ts +2 -2
- package/dashboard/app/assets/css/main.css +285 -13
- package/dashboard/app/components/ArkaConstellation.vue +239 -0
- package/dashboard/app/components/ArkaCountUp.vue +35 -0
- package/dashboard/app/components/ArkaGlowCard.vue +25 -0
- package/dashboard/app/components/ArkaLiveFeed.vue +122 -0
- package/dashboard/app/components/ArkaNavGroupLabel.vue +11 -0
- package/dashboard/app/components/ArkaPageHero.vue +42 -0
- package/dashboard/app/components/ArkaSection.vue +20 -0
- package/dashboard/app/components/ArkaStarfield.vue +3 -0
- package/dashboard/app/components/ArkaStatCard.vue +38 -0
- package/dashboard/app/components/ArkaSystemPill.vue +54 -0
- package/dashboard/app/components/ArkaTrendChart.vue +68 -0
- package/dashboard/app/composables/useCountUp.ts +30 -0
- package/dashboard/app/composables/useDashboard.ts +2 -1
- package/dashboard/app/composables/useTaskStream.ts +39 -0
- package/dashboard/app/layouts/default.vue +11 -2
- package/dashboard/app/pages/design-lab.vue +257 -0
- package/dashboard/app/pages/index.vue +268 -213
- package/dashboard/nuxt.config.ts +21 -0
- package/dashboard/package.json +1 -0
- package/departments/content/skills/video-produce/SKILL.md +9 -1
- package/departments/content/skills/video-setup/SKILL.md +15 -4
- package/installer/doctor.js +13 -8
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/start-dashboard.sh +3 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Canvas particle field: 86 agents clustered around 17 department hubs.
|
|
3
|
+
// Breathing amplitude scales with the department's calls_30d. Hover finds
|
|
4
|
+
// the nearest node (tooltip); click navigates. rAF pauses when the tab is
|
|
5
|
+
// hidden; prefers-reduced-motion renders one static frame.
|
|
6
|
+
import { useDocumentVisibility, useElementSize } from '@vueuse/core'
|
|
7
|
+
|
|
8
|
+
interface AgentNode {
|
|
9
|
+
id: string
|
|
10
|
+
name: string
|
|
11
|
+
department: string
|
|
12
|
+
tier: number
|
|
13
|
+
}
|
|
14
|
+
interface DeptMeta {
|
|
15
|
+
department: string
|
|
16
|
+
calls_30d: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<{
|
|
20
|
+
agents: AgentNode[]
|
|
21
|
+
departments: DeptMeta[]
|
|
22
|
+
height?: number
|
|
23
|
+
}>(), {
|
|
24
|
+
height: 380
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const router = useRouter()
|
|
28
|
+
const wrap = ref<HTMLElement | null>(null)
|
|
29
|
+
const canvas = ref<HTMLCanvasElement | null>(null)
|
|
30
|
+
const { width } = useElementSize(wrap)
|
|
31
|
+
const visibility = useDocumentVisibility()
|
|
32
|
+
|
|
33
|
+
const hovered = ref<{ x: number, y: number, name: string, department: string } | null>(null)
|
|
34
|
+
|
|
35
|
+
interface Star {
|
|
36
|
+
agent: AgentNode
|
|
37
|
+
cx: number
|
|
38
|
+
cy: number
|
|
39
|
+
baseR: number
|
|
40
|
+
phase: number
|
|
41
|
+
speed: number
|
|
42
|
+
intensity: number
|
|
43
|
+
x: number
|
|
44
|
+
y: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let stars: Star[] = []
|
|
48
|
+
let hubs: Record<string, { x: number, y: number }> = {}
|
|
49
|
+
let frame = 0
|
|
50
|
+
let time = 0
|
|
51
|
+
|
|
52
|
+
// Canvas has no reliable color-mix() support below Chromium 119 /
|
|
53
|
+
// Safari 17.2, so token hexes are resolved to rgba() strings instead.
|
|
54
|
+
function cssVar(name: string, fallback: string) {
|
|
55
|
+
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
|
|
56
|
+
}
|
|
57
|
+
function rgba(hex: string, alpha: number) {
|
|
58
|
+
const h = hex.replace('#', '')
|
|
59
|
+
if (!/^[0-9a-f]{3}$|^[0-9a-f]{6}$/i.test(h)) return hex
|
|
60
|
+
const n = h.length === 3 ? h.split('').map(c => c + c).join('') : h
|
|
61
|
+
const int = Number.parseInt(n, 16)
|
|
62
|
+
return `rgba(${(int >> 16) & 255}, ${(int >> 8) & 255}, ${int & 255}, ${alpha})`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function layoutHubs(w: number, h: number, depts: string[]) {
|
|
66
|
+
hubs = {}
|
|
67
|
+
depts.forEach((dept, i) => {
|
|
68
|
+
// Golden-angle spiral keeps 17 hubs evenly spread at any aspect ratio.
|
|
69
|
+
const angle = i * 2.399963
|
|
70
|
+
const radius = 0.16 + 0.34 * Math.sqrt((i + 0.5) / depts.length)
|
|
71
|
+
hubs[dept] = {
|
|
72
|
+
x: w / 2 + Math.cos(angle) * radius * w * 0.9,
|
|
73
|
+
y: h / 2 + Math.sin(angle) * radius * h * 0.85
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function layoutStars(w: number, h: number) {
|
|
79
|
+
const maxCalls = Math.max(1, ...props.departments.map(d => d.calls_30d))
|
|
80
|
+
const callsByDept = Object.fromEntries(props.departments.map(d => [d.department, d.calls_30d]))
|
|
81
|
+
stars = props.agents.map((agent, i) => {
|
|
82
|
+
const hub = hubs[agent.department] ?? { x: w / 2, y: h / 2 }
|
|
83
|
+
const angle = (i * 2.399963) % (Math.PI * 2)
|
|
84
|
+
const spread = 18 + (i % 5) * 9
|
|
85
|
+
return {
|
|
86
|
+
agent,
|
|
87
|
+
cx: hub.x + Math.cos(angle) * spread,
|
|
88
|
+
cy: hub.y + Math.sin(angle) * spread * 0.7,
|
|
89
|
+
baseR: agent.tier === 0 ? 3.2 : agent.tier === 1 ? 2.6 : 2,
|
|
90
|
+
phase: Math.random() * Math.PI * 2,
|
|
91
|
+
speed: 0.4 + Math.random() * 0.5,
|
|
92
|
+
intensity: (callsByDept[agent.department] ?? 0) / maxCalls,
|
|
93
|
+
x: 0,
|
|
94
|
+
y: 0
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function layout() {
|
|
100
|
+
const w = width.value || 800
|
|
101
|
+
const depts = [...new Set(props.agents.map(a => a.department))].sort()
|
|
102
|
+
layoutHubs(w, props.height, depts)
|
|
103
|
+
layoutStars(w, props.height)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function drawLinks(ctx: CanvasRenderingContext2D, gray: string, animate: boolean) {
|
|
107
|
+
ctx.lineWidth = 0.5
|
|
108
|
+
ctx.strokeStyle = rgba(gray, 0.12)
|
|
109
|
+
for (const s of stars) {
|
|
110
|
+
const hub = hubs[s.agent.department]
|
|
111
|
+
if (!hub) continue
|
|
112
|
+
const drift = animate ? Math.sin(time * s.speed + s.phase) * (2 + s.intensity * 4) : 0
|
|
113
|
+
s.x = s.cx + drift
|
|
114
|
+
s.y = s.cy + Math.cos(time * s.speed * 0.8 + s.phase) * (animate ? 2 + s.intensity * 3 : 0)
|
|
115
|
+
ctx.beginPath()
|
|
116
|
+
ctx.moveTo(hub.x, hub.y)
|
|
117
|
+
ctx.lineTo(s.x, s.y)
|
|
118
|
+
ctx.stroke()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function drawStars(ctx: CanvasRenderingContext2D, green: string, animate: boolean) {
|
|
123
|
+
for (const s of stars) {
|
|
124
|
+
const breath = animate ? (Math.sin(time * 1.4 * s.speed + s.phase) + 1) / 2 : 0.5
|
|
125
|
+
const r = s.baseR + breath * (0.6 + s.intensity * 1.6)
|
|
126
|
+
const alpha = Math.min(1, 0.45 + s.intensity * 0.4 + breath * 0.15)
|
|
127
|
+
ctx.beginPath()
|
|
128
|
+
ctx.arc(s.x, s.y, r, 0, Math.PI * 2)
|
|
129
|
+
ctx.fillStyle = rgba(green, alpha)
|
|
130
|
+
ctx.shadowColor = s.intensity > 0.15 ? green : 'transparent'
|
|
131
|
+
ctx.shadowBlur = s.intensity * 14
|
|
132
|
+
ctx.fill()
|
|
133
|
+
ctx.shadowBlur = 0
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function drawHubLabels(ctx: CanvasRenderingContext2D, gray: string) {
|
|
138
|
+
ctx.font = '9px "JetBrains Mono", monospace'
|
|
139
|
+
ctx.fillStyle = rgba(gray, 0.65)
|
|
140
|
+
ctx.textAlign = 'center'
|
|
141
|
+
for (const [dept, hub] of Object.entries(hubs)) {
|
|
142
|
+
ctx.fillText(dept.toUpperCase(), hub.x, hub.y - 16)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function draw(animate: boolean) {
|
|
147
|
+
const el = canvas.value
|
|
148
|
+
const ctx = el?.getContext('2d')
|
|
149
|
+
if (!el || !ctx) return
|
|
150
|
+
const w = width.value || 800
|
|
151
|
+
const dpr = window.devicePixelRatio || 1
|
|
152
|
+
if (el.width !== w * dpr || el.height !== props.height * dpr) {
|
|
153
|
+
el.width = w * dpr
|
|
154
|
+
el.height = props.height * dpr
|
|
155
|
+
}
|
|
156
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
157
|
+
ctx.clearRect(0, 0, w, props.height)
|
|
158
|
+
const green = cssVar('--ui-primary', '#00FF88')
|
|
159
|
+
const gray = cssVar('--ui-text-muted', '#8A958F')
|
|
160
|
+
drawLinks(ctx, gray, animate)
|
|
161
|
+
drawStars(ctx, green, animate)
|
|
162
|
+
drawHubLabels(ctx, gray)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function loop() {
|
|
166
|
+
time += 0.016
|
|
167
|
+
draw(true)
|
|
168
|
+
frame = requestAnimationFrame(loop)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function start() {
|
|
172
|
+
cancelAnimationFrame(frame)
|
|
173
|
+
layout()
|
|
174
|
+
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
175
|
+
if (reduce) {
|
|
176
|
+
draw(false)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
loop()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
watch([width, () => props.agents, visibility], () => {
|
|
183
|
+
cancelAnimationFrame(frame)
|
|
184
|
+
if (visibility.value !== 'visible') return
|
|
185
|
+
if (props.agents.length) start()
|
|
186
|
+
}, { deep: false })
|
|
187
|
+
|
|
188
|
+
onMounted(() => {
|
|
189
|
+
if (props.agents.length) start()
|
|
190
|
+
})
|
|
191
|
+
onUnmounted(() => cancelAnimationFrame(frame))
|
|
192
|
+
|
|
193
|
+
function onMove(e: MouseEvent) {
|
|
194
|
+
const rect = canvas.value?.getBoundingClientRect()
|
|
195
|
+
if (!rect) return
|
|
196
|
+
const mx = e.clientX - rect.left
|
|
197
|
+
const my = e.clientY - rect.top
|
|
198
|
+
let best: Star | null = null
|
|
199
|
+
let bestDist = 18
|
|
200
|
+
for (const s of stars) {
|
|
201
|
+
const d = Math.hypot(s.x - mx, s.y - my)
|
|
202
|
+
if (d < bestDist) {
|
|
203
|
+
best = s
|
|
204
|
+
bestDist = d
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
hovered.value = best
|
|
208
|
+
? { x: best.x, y: best.y, name: best.agent.name, department: best.agent.department }
|
|
209
|
+
: null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function onClick() {
|
|
213
|
+
if (hovered.value) router.push('/agents')
|
|
214
|
+
}
|
|
215
|
+
</script>
|
|
216
|
+
|
|
217
|
+
<template>
|
|
218
|
+
<div ref="wrap" class="relative w-full" :style="{ height: `${height}px` }">
|
|
219
|
+
<canvas
|
|
220
|
+
ref="canvas"
|
|
221
|
+
class="size-full"
|
|
222
|
+
:class="hovered ? 'cursor-pointer' : ''"
|
|
223
|
+
:style="{ width: '100%', height: `${height}px` }"
|
|
224
|
+
role="img"
|
|
225
|
+
aria-label="Constellation of all agents grouped by department"
|
|
226
|
+
@mousemove="onMove"
|
|
227
|
+
@mouseleave="hovered = null"
|
|
228
|
+
@click="onClick"
|
|
229
|
+
/>
|
|
230
|
+
<div
|
|
231
|
+
v-if="hovered"
|
|
232
|
+
class="pointer-events-none absolute z-10 -translate-x-1/2 rounded border border-default bg-elevated px-2 py-1"
|
|
233
|
+
:style="{ left: `${hovered.x}px`, top: `${hovered.y - 34}px` }"
|
|
234
|
+
>
|
|
235
|
+
<span class="text-xs font-medium">{{ hovered.name }}</span>
|
|
236
|
+
<span class="arka-data ml-1.5 text-[10px] text-muted uppercase">{{ hovered.department }}</span>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = withDefaults(defineProps<{
|
|
3
|
+
value: number
|
|
4
|
+
format?: 'currency' | 'number' | 'percent' | 'tokens'
|
|
5
|
+
decimals?: number
|
|
6
|
+
duration?: number
|
|
7
|
+
}>(), {
|
|
8
|
+
format: 'number',
|
|
9
|
+
decimals: 0,
|
|
10
|
+
duration: 900
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const target = computed(() => props.value ?? 0)
|
|
14
|
+
const { display } = useCountUp(target, props.duration)
|
|
15
|
+
|
|
16
|
+
const formatted = computed(() => {
|
|
17
|
+
const v = display.value
|
|
18
|
+
if (props.format === 'currency') {
|
|
19
|
+
return `$${v.toFixed(Math.max(props.decimals, 2))}`
|
|
20
|
+
}
|
|
21
|
+
if (props.format === 'percent') {
|
|
22
|
+
return `${(v * 100).toFixed(props.decimals)}%`
|
|
23
|
+
}
|
|
24
|
+
if (props.format === 'tokens') {
|
|
25
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
|
|
26
|
+
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`
|
|
27
|
+
return Math.round(v).toString()
|
|
28
|
+
}
|
|
29
|
+
return Math.round(v).toLocaleString('en-US')
|
|
30
|
+
})
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<span class="arka-data">{{ formatted }}</span>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(defineProps<{
|
|
3
|
+
live?: boolean
|
|
4
|
+
interactive?: boolean
|
|
5
|
+
as?: string
|
|
6
|
+
padded?: boolean
|
|
7
|
+
}>(), {
|
|
8
|
+
live: false,
|
|
9
|
+
interactive: false,
|
|
10
|
+
as: 'div',
|
|
11
|
+
padded: true
|
|
12
|
+
})
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<component
|
|
17
|
+
:is="as"
|
|
18
|
+
class="arka-glow-card"
|
|
19
|
+
:class="padded ? 'p-4 sm:p-5' : ''"
|
|
20
|
+
:data-live="live || undefined"
|
|
21
|
+
:data-interactive="interactive || undefined"
|
|
22
|
+
>
|
|
23
|
+
<slot />
|
|
24
|
+
</component>
|
|
25
|
+
</template>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// One stream, two sources: /api/audit (poll) for enforcement events and
|
|
3
|
+
// /ws/tasks for job lifecycle. WS failure degrades to poll-only.
|
|
4
|
+
import { useDocumentVisibility } from '@vueuse/core'
|
|
5
|
+
|
|
6
|
+
interface AuditEvent {
|
|
7
|
+
ts: string
|
|
8
|
+
tool: string
|
|
9
|
+
reason: string
|
|
10
|
+
kind: string
|
|
11
|
+
}
|
|
12
|
+
interface FeedItem {
|
|
13
|
+
id: string
|
|
14
|
+
ts: number
|
|
15
|
+
icon: string
|
|
16
|
+
tone: 'primary' | 'warning' | 'error' | 'neutral'
|
|
17
|
+
title: string
|
|
18
|
+
detail: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const props = withDefaults(defineProps<{
|
|
22
|
+
limit?: number
|
|
23
|
+
pollInterval?: number
|
|
24
|
+
}>(), {
|
|
25
|
+
limit: 12,
|
|
26
|
+
pollInterval: 15_000
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const { apiBase } = useApi()
|
|
30
|
+
const visibility = useDocumentVisibility()
|
|
31
|
+
const items = ref<FeedItem[]>([])
|
|
32
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
33
|
+
|
|
34
|
+
function pushItems(next: FeedItem[]) {
|
|
35
|
+
const seen = new Set(items.value.map(i => i.id))
|
|
36
|
+
const fresh = next.filter(i => !seen.has(i.id))
|
|
37
|
+
if (!fresh.length) return
|
|
38
|
+
items.value = [...fresh, ...items.value]
|
|
39
|
+
.sort((a, b) => b.ts - a.ts)
|
|
40
|
+
.slice(0, props.limit)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function pollAudit() {
|
|
44
|
+
try {
|
|
45
|
+
const res = await $fetch<{ events: AuditEvent[] }>(`${apiBase}/api/audit`, { query: { limit: props.limit } })
|
|
46
|
+
pushItems((res.events || []).map(e => ({
|
|
47
|
+
id: `audit-${e.ts}-${e.tool}-${(e.reason || '').slice(0, 32)}`,
|
|
48
|
+
ts: Date.parse(e.ts) || Date.now(),
|
|
49
|
+
icon: e.kind === 'bypass' ? 'i-lucide-shield-off' : 'i-lucide-shield',
|
|
50
|
+
tone: e.kind === 'bypass' ? 'warning' : 'neutral',
|
|
51
|
+
title: e.tool || e.kind,
|
|
52
|
+
detail: e.reason || e.kind
|
|
53
|
+
})))
|
|
54
|
+
} catch {
|
|
55
|
+
// API down — keep whatever we have
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
useTaskStream((e) => {
|
|
60
|
+
const toneMap = { job_complete: 'primary', job_failed: 'error', job_cancelled: 'warning', job_progress: 'neutral' } as const
|
|
61
|
+
const iconMap = {
|
|
62
|
+
job_complete: 'i-lucide-check-circle',
|
|
63
|
+
job_failed: 'i-lucide-x-circle',
|
|
64
|
+
job_cancelled: 'i-lucide-circle-slash',
|
|
65
|
+
job_progress: 'i-lucide-loader'
|
|
66
|
+
} as const
|
|
67
|
+
pushItems([{
|
|
68
|
+
id: `job-${e.job_id}-${e.type}-${e.ts}`,
|
|
69
|
+
ts: e.ts,
|
|
70
|
+
icon: iconMap[e.type],
|
|
71
|
+
tone: toneMap[e.type],
|
|
72
|
+
title: e.type.replace('job_', 'job '),
|
|
73
|
+
detail: e.message || e.error || e.job_id
|
|
74
|
+
}])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
onMounted(() => {
|
|
78
|
+
pollAudit()
|
|
79
|
+
timer = setInterval(() => {
|
|
80
|
+
if (visibility.value === 'visible') pollAudit()
|
|
81
|
+
}, props.pollInterval)
|
|
82
|
+
})
|
|
83
|
+
onUnmounted(() => clearInterval(timer))
|
|
84
|
+
|
|
85
|
+
function timeLabel(ts: number) {
|
|
86
|
+
return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
87
|
+
}
|
|
88
|
+
const toneClass = { primary: 'text-primary', warning: 'text-warning', error: 'text-error', neutral: 'text-muted' }
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<ArkaGlowCard :padded="false">
|
|
93
|
+
<div v-if="!items.length" class="p-6 text-center">
|
|
94
|
+
<p class="arka-data text-xs text-muted">
|
|
95
|
+
listening for signals…
|
|
96
|
+
</p>
|
|
97
|
+
</div>
|
|
98
|
+
<TransitionGroup
|
|
99
|
+
v-else
|
|
100
|
+
tag="ul"
|
|
101
|
+
class="divide-y divide-default"
|
|
102
|
+
enter-active-class="arka-stream-in"
|
|
103
|
+
>
|
|
104
|
+
<li
|
|
105
|
+
v-for="item in items"
|
|
106
|
+
:key="item.id"
|
|
107
|
+
class="flex items-center gap-3 px-4 py-2.5"
|
|
108
|
+
>
|
|
109
|
+
<UIcon :name="item.icon" class="size-4 shrink-0" :class="toneClass[item.tone]" />
|
|
110
|
+
<div class="min-w-0 flex-1">
|
|
111
|
+
<p class="truncate text-sm">
|
|
112
|
+
{{ item.title }}
|
|
113
|
+
</p>
|
|
114
|
+
<p class="truncate text-xs text-muted">
|
|
115
|
+
{{ item.detail }}
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
<span class="arka-data shrink-0 text-[10px] text-muted">{{ timeLabel(item.ts) }}</span>
|
|
119
|
+
</li>
|
|
120
|
+
</TransitionGroup>
|
|
121
|
+
</ArkaGlowCard>
|
|
122
|
+
</template>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useTimestamp } from '@vueuse/core'
|
|
3
|
+
|
|
4
|
+
withDefaults(defineProps<{
|
|
5
|
+
numeral: string
|
|
6
|
+
label: string
|
|
7
|
+
title: string
|
|
8
|
+
subtitle?: string
|
|
9
|
+
clock?: boolean
|
|
10
|
+
}>(), {
|
|
11
|
+
subtitle: '',
|
|
12
|
+
clock: true
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const timestamp = useTimestamp({ interval: 1000 })
|
|
16
|
+
const clockLabel = computed(() =>
|
|
17
|
+
new Date(timestamp.value).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
|
18
|
+
)
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<header class="space-y-3">
|
|
23
|
+
<div class="flex items-center gap-3">
|
|
24
|
+
<span class="arka-numeral text-2xl leading-none">{{ numeral }}.</span>
|
|
25
|
+
<span class="h-px w-10 bg-[var(--ui-border)]" aria-hidden="true" />
|
|
26
|
+
<span class="arka-eyebrow">{{ label }}</span>
|
|
27
|
+
<div class="ml-auto flex items-center gap-2">
|
|
28
|
+
<slot name="actions" />
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<h1 class="arka-serif-title text-4xl sm:text-5xl">
|
|
32
|
+
{{ title }}
|
|
33
|
+
</h1>
|
|
34
|
+
<p v-if="subtitle" class="max-w-2xl text-muted">
|
|
35
|
+
{{ subtitle }}
|
|
36
|
+
</p>
|
|
37
|
+
<p v-if="clock" class="arka-data text-xs text-muted uppercase tracking-wide">
|
|
38
|
+
{{ clockLabel }} · local · studio
|
|
39
|
+
</p>
|
|
40
|
+
<slot name="stats" />
|
|
41
|
+
</header>
|
|
42
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
numeral: string
|
|
4
|
+
label: string
|
|
5
|
+
}>()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<section class="space-y-5">
|
|
10
|
+
<div class="flex items-center gap-3">
|
|
11
|
+
<span class="arka-numeral text-xl leading-none">{{ numeral }}.</span>
|
|
12
|
+
<span class="h-px w-8 bg-[var(--ui-border)]" aria-hidden="true" />
|
|
13
|
+
<span class="arka-eyebrow">{{ label }}</span>
|
|
14
|
+
<div class="ml-auto flex items-center gap-2">
|
|
15
|
+
<slot name="actions" />
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
<slot />
|
|
19
|
+
</section>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(defineProps<{
|
|
3
|
+
label: string
|
|
4
|
+
value: number | null
|
|
5
|
+
format?: 'currency' | 'number' | 'percent' | 'tokens'
|
|
6
|
+
decimals?: number
|
|
7
|
+
accent?: boolean
|
|
8
|
+
hint?: string
|
|
9
|
+
}>(), {
|
|
10
|
+
format: 'number',
|
|
11
|
+
decimals: 0,
|
|
12
|
+
accent: false,
|
|
13
|
+
hint: ''
|
|
14
|
+
})
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<ArkaGlowCard :live="accent" class="min-w-0">
|
|
19
|
+
<div class="space-y-2" :class="accent ? 'border-l-2 border-primary -ml-4 pl-4 sm:-ml-5 sm:pl-5' : ''">
|
|
20
|
+
<p class="arka-eyebrow">
|
|
21
|
+
{{ label }}
|
|
22
|
+
</p>
|
|
23
|
+
<p class="text-3xl font-medium leading-none">
|
|
24
|
+
<ArkaCountUp
|
|
25
|
+
v-if="value !== null"
|
|
26
|
+
:value="value"
|
|
27
|
+
:format="format"
|
|
28
|
+
:decimals="decimals"
|
|
29
|
+
/>
|
|
30
|
+
<span v-else class="arka-data text-muted">—</span>
|
|
31
|
+
</p>
|
|
32
|
+
<p v-if="hint" class="text-xs text-muted">
|
|
33
|
+
{{ hint }}
|
|
34
|
+
</p>
|
|
35
|
+
<slot name="sparkline" />
|
|
36
|
+
</div>
|
|
37
|
+
</ArkaGlowCard>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// "all systems live" — the shell's heartbeat. Polls /api/health (30s),
|
|
3
|
+
// pauses while the tab is hidden.
|
|
4
|
+
import { useDocumentVisibility } from '@vueuse/core'
|
|
5
|
+
|
|
6
|
+
interface HealthPayload {
|
|
7
|
+
healthy: boolean
|
|
8
|
+
passed: number
|
|
9
|
+
total: number
|
|
10
|
+
failed_blocking: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { apiBase } = useApi()
|
|
14
|
+
const health = ref<HealthPayload | null>(null)
|
|
15
|
+
const visibility = useDocumentVisibility()
|
|
16
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
17
|
+
|
|
18
|
+
async function poll() {
|
|
19
|
+
try {
|
|
20
|
+
health.value = await $fetch<HealthPayload>(`${apiBase}/api/health`)
|
|
21
|
+
} catch {
|
|
22
|
+
health.value = null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onMounted(() => {
|
|
27
|
+
poll()
|
|
28
|
+
timer = setInterval(() => {
|
|
29
|
+
if (visibility.value === 'visible') poll()
|
|
30
|
+
}, 30_000)
|
|
31
|
+
})
|
|
32
|
+
onUnmounted(() => clearInterval(timer))
|
|
33
|
+
|
|
34
|
+
const label = computed(() => {
|
|
35
|
+
if (!health.value) return 'link down'
|
|
36
|
+
if (health.value.healthy) return 'all systems live'
|
|
37
|
+
return `${health.value.failed_blocking || health.value.total - health.value.passed} degraded`
|
|
38
|
+
})
|
|
39
|
+
const tone = computed(() => {
|
|
40
|
+
if (!health.value) return 'text-muted'
|
|
41
|
+
return health.value.healthy ? 'text-primary' : 'text-warning'
|
|
42
|
+
})
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<NuxtLink
|
|
47
|
+
to="/health"
|
|
48
|
+
class="flex items-center gap-2 rounded-full border border-default bg-elevated px-3 py-1.5 transition-colors hover:border-primary/40"
|
|
49
|
+
>
|
|
50
|
+
<span v-if="health?.healthy" class="arka-live-dot" />
|
|
51
|
+
<span v-else class="size-2 rounded-full" :class="health ? 'bg-warning' : 'bg-carbon-500'" />
|
|
52
|
+
<span class="arka-data text-xs" :class="tone">{{ label }}</span>
|
|
53
|
+
</NuxtLink>
|
|
54
|
+
</template>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { VisXYContainer, VisArea, VisLine, VisAxis } from '@unovis/vue'
|
|
3
|
+
|
|
4
|
+
interface TrendPoint {
|
|
5
|
+
date: string
|
|
6
|
+
value: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const props = withDefaults(defineProps<{
|
|
10
|
+
series: TrendPoint[]
|
|
11
|
+
height?: number
|
|
12
|
+
}>(), {
|
|
13
|
+
height: 160
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const x = (_: TrendPoint, i: number) => i
|
|
17
|
+
const y = (d: TrendPoint) => d.value
|
|
18
|
+
|
|
19
|
+
const tickValues = computed(() => {
|
|
20
|
+
const n = props.series.length
|
|
21
|
+
if (n <= 7) return props.series.map((_, i) => i)
|
|
22
|
+
const step = Math.ceil(n / 6)
|
|
23
|
+
return props.series.map((_, i) => i).filter(i => i % step === 0)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function tickFormat(i: number) {
|
|
27
|
+
const point = props.series[Math.round(i)]
|
|
28
|
+
if (!point) return ''
|
|
29
|
+
return point.date.slice(5)
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div class="arka-trend-chart">
|
|
35
|
+
<VisXYContainer :data="series" :height="height">
|
|
36
|
+
<VisArea
|
|
37
|
+
:x="x"
|
|
38
|
+
:y="y"
|
|
39
|
+
color="var(--ui-primary)"
|
|
40
|
+
:opacity="0.12"
|
|
41
|
+
curve-type="monotoneX"
|
|
42
|
+
/>
|
|
43
|
+
<VisLine
|
|
44
|
+
:x="x"
|
|
45
|
+
:y="y"
|
|
46
|
+
color="var(--ui-primary)"
|
|
47
|
+
:line-width="1.5"
|
|
48
|
+
curve-type="monotoneX"
|
|
49
|
+
/>
|
|
50
|
+
<VisAxis
|
|
51
|
+
type="x"
|
|
52
|
+
:tick-values="tickValues"
|
|
53
|
+
:tick-format="tickFormat"
|
|
54
|
+
:grid-line="false"
|
|
55
|
+
:domain-line="false"
|
|
56
|
+
:tick-line="false"
|
|
57
|
+
/>
|
|
58
|
+
</VisXYContainer>
|
|
59
|
+
</div>
|
|
60
|
+
</template>
|
|
61
|
+
|
|
62
|
+
<style scoped>
|
|
63
|
+
.arka-trend-chart :deep(text) {
|
|
64
|
+
font-family: var(--font-mono);
|
|
65
|
+
font-size: 10px;
|
|
66
|
+
fill: var(--ui-text-muted);
|
|
67
|
+
}
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// rAF count-up on the shared motion rhythm. Reduced-motion (or a zero
|
|
2
|
+
// duration) jumps straight to the target so numbers are never withheld.
|
|
3
|
+
export function useCountUp(target: Ref<number>, duration = 900) {
|
|
4
|
+
const display = ref(0)
|
|
5
|
+
let frame = 0
|
|
6
|
+
|
|
7
|
+
const run = (to: number) => {
|
|
8
|
+
cancelAnimationFrame(frame)
|
|
9
|
+
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
10
|
+
if (reduce || duration <= 0 || !Number.isFinite(to)) {
|
|
11
|
+
display.value = to
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
const from = display.value
|
|
15
|
+
const start = performance.now()
|
|
16
|
+
const tick = (now: number) => {
|
|
17
|
+
const t = Math.min((now - start) / duration, 1)
|
|
18
|
+
const eased = 1 - Math.pow(1 - t, 3)
|
|
19
|
+
display.value = from + (to - from) * eased
|
|
20
|
+
if (t < 1) frame = requestAnimationFrame(tick)
|
|
21
|
+
else display.value = to
|
|
22
|
+
}
|
|
23
|
+
frame = requestAnimationFrame(tick)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
watch(target, run, { immediate: true })
|
|
27
|
+
onUnmounted(() => cancelAnimationFrame(frame))
|
|
28
|
+
|
|
29
|
+
return { display }
|
|
30
|
+
}
|