helixevo 0.2.0 → 0.2.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.
@@ -0,0 +1,298 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, readdirSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { homedir } from 'os'
5
+
6
+ const HELIX_DIR = join(homedir(), '.helix')
7
+ const GENERAL_DIR = join(HELIX_DIR, 'general')
8
+
9
+ // ─── Helpers ────────────────────────────────────────────────────
10
+
11
+ function readSkillMd(slug: string): { raw: string; meta: Record<string, unknown>; content: string } | null {
12
+ const path = join(GENERAL_DIR, slug, 'SKILL.md')
13
+ if (!existsSync(path)) return null
14
+ const raw = readFileSync(path, 'utf-8')
15
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
16
+ if (!match) return { raw, meta: {}, content: raw }
17
+
18
+ // Simple YAML parse (avoid importing yaml in API route)
19
+ const meta: Record<string, unknown> = {}
20
+ for (const line of match[1].split('\n')) {
21
+ const m = line.match(/^(\w[\w-]*?):\s*(.*)/)
22
+ if (m) {
23
+ let val: unknown = m[2].trim()
24
+ if (val === 'true') val = true
25
+ else if (val === 'false') val = false
26
+ else if (/^\d+(\.\d+)?$/.test(val as string)) val = parseFloat(val as string)
27
+ else if ((val as string).startsWith('[')) {
28
+ try { val = JSON.parse((val as string).replace(/'/g, '"')) } catch { /* keep as string */ }
29
+ }
30
+ // Remove quotes
31
+ if (typeof val === 'string' && val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1)
32
+ meta[m[1]] = val
33
+ }
34
+ }
35
+
36
+ return { raw, meta, content: match[2].trim() }
37
+ }
38
+
39
+ function loadGraph(): { nodes: { id: string; layer: string }[]; edges: { from: string; to: string; type: string }[] } {
40
+ const path = join(HELIX_DIR, 'skill-graph.json')
41
+ if (!existsSync(path)) return { nodes: [], edges: [] }
42
+ return JSON.parse(readFileSync(path, 'utf-8'))
43
+ }
44
+
45
+ function listSkills(): string[] {
46
+ if (!existsSync(GENERAL_DIR)) return []
47
+ return readdirSync(GENERAL_DIR, { withFileTypes: true })
48
+ .filter(d => d.isDirectory() && !d.name.startsWith('.'))
49
+ .map(d => d.name)
50
+ }
51
+
52
+ // ─── Network adaptation analysis ────────────────────────────────
53
+
54
+ interface AdaptationResult {
55
+ status: 'ok' | 'warning' | 'action-needed'
56
+ messages: { type: 'info' | 'warning' | 'action'; text: string }[]
57
+ suggestions: { type: 'create' | 'merge' | 'promote' | 'rewire'; description: string }[]
58
+ }
59
+
60
+ function analyzeNetworkAdaptation(
61
+ action: 'update' | 'delete' | 'create' | 'promote',
62
+ slug: string,
63
+ newLayer?: string
64
+ ): AdaptationResult {
65
+ const graph = loadGraph()
66
+ const result: AdaptationResult = { status: 'ok', messages: [], suggestions: [] }
67
+
68
+ if (action === 'delete') {
69
+ // Check what depends on this skill
70
+ const dependents = graph.edges.filter(e => e.from === slug && e.type === 'inherits')
71
+ const enhancedBy = graph.edges.filter(e => (e.from === slug || e.to === slug) && e.type === 'enhances')
72
+ const dependencies = graph.edges.filter(e => e.to === slug && e.type === 'depends')
73
+
74
+ if (dependents.length > 0) {
75
+ result.status = 'action-needed'
76
+ result.messages.push({
77
+ type: 'warning',
78
+ text: `${dependents.length} skill(s) inherit from "${slug}": ${dependents.map(e => e.to).join(', ')}. They will become orphans.`
79
+ })
80
+ result.suggestions.push({
81
+ type: 'rewire',
82
+ description: `Re-assign children [${dependents.map(e => e.to).join(', ')}] to another parent before deleting`
83
+ })
84
+ }
85
+
86
+ if (dependencies.length > 0) {
87
+ result.status = 'warning'
88
+ result.messages.push({
89
+ type: 'warning',
90
+ text: `${dependencies.length} skill(s) depend on "${slug}": ${dependencies.map(e => e.from).join(', ')}`
91
+ })
92
+ }
93
+
94
+ if (enhancedBy.length > 0) {
95
+ result.messages.push({
96
+ type: 'info',
97
+ text: `${enhancedBy.length} enhancement edge(s) will be removed`
98
+ })
99
+ }
100
+
101
+ // Check if deletion creates a coverage gap
102
+ const allSlugs = listSkills()
103
+ if (allSlugs.length <= 3) {
104
+ result.status = 'warning'
105
+ result.messages.push({
106
+ type: 'warning',
107
+ text: 'Removing this skill will leave very few skills. Consider merging instead of deleting.'
108
+ })
109
+ }
110
+ }
111
+
112
+ if (action === 'update') {
113
+ // Check if co-evolved skills might be affected
114
+ const coEvolved = graph.edges.filter(
115
+ e => (e.from === slug || e.to === slug) && (e.type === 'co-evolves' || e.type === 'enhances')
116
+ )
117
+ if (coEvolved.length > 0) {
118
+ const partners = coEvolved.map(e => e.from === slug ? e.to : e.from)
119
+ result.messages.push({
120
+ type: 'info',
121
+ text: `This skill co-evolves with [${partners.join(', ')}]. Changes may affect their behavior.`
122
+ })
123
+ result.suggestions.push({
124
+ type: 'rewire',
125
+ description: `Review golden cases for [${partners.join(', ')}] after this change`
126
+ })
127
+ }
128
+ }
129
+
130
+ if (action === 'promote') {
131
+ // Check if promotion makes sense
132
+ const currentSkill = readSkillMd(slug)
133
+ const currentLayer = (currentSkill?.meta?.layer as string) ?? 'domain'
134
+
135
+ if (newLayer === 'system' && currentLayer === 'project') {
136
+ result.status = 'warning'
137
+ result.messages.push({
138
+ type: 'warning',
139
+ text: 'Promoting directly from project to system. Consider promoting to domain first.'
140
+ })
141
+ }
142
+
143
+ // Check if similar domain skill already exists
144
+ const allSlugs = listSkills()
145
+ const domainSkills = allSlugs.filter(s => {
146
+ const sk = readSkillMd(s)
147
+ return sk?.meta?.layer === 'domain' && s !== slug
148
+ })
149
+
150
+ if (domainSkills.length > 0 && newLayer === 'domain') {
151
+ result.messages.push({
152
+ type: 'info',
153
+ text: `${domainSkills.length} domain skill(s) exist. Check for overlap before promoting.`
154
+ })
155
+ result.suggestions.push({
156
+ type: 'merge',
157
+ description: `Consider merging with existing domain skills instead of creating a new one`
158
+ })
159
+ }
160
+ }
161
+
162
+ if (action === 'create') {
163
+ // Check if similar skill already exists
164
+ const existingSlugs = listSkills()
165
+ if (existingSlugs.includes(slug)) {
166
+ result.status = 'warning'
167
+ result.messages.push({
168
+ type: 'warning',
169
+ text: `Skill "${slug}" already exists. This will overwrite it.`
170
+ })
171
+ }
172
+
173
+ // Suggest connecting to existing skills
174
+ if (graph.nodes.length > 0) {
175
+ result.suggestions.push({
176
+ type: 'rewire',
177
+ description: 'After creating, run "helixevo graph --rebuild" to detect relationships with existing skills'
178
+ })
179
+ }
180
+ }
181
+
182
+ if (result.messages.length === 0) {
183
+ result.messages.push({ type: 'info', text: 'Network can adapt to this change without issues.' })
184
+ }
185
+
186
+ return result
187
+ }
188
+
189
+ // ─── GET: List all skills or get one ────────────────────────────
190
+
191
+ export async function GET(request: NextRequest) {
192
+ const slug = request.nextUrl.searchParams.get('slug')
193
+
194
+ if (slug) {
195
+ const skill = readSkillMd(slug)
196
+ if (!skill) return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
197
+ return NextResponse.json({ slug, ...skill })
198
+ }
199
+
200
+ // List all skills
201
+ const slugs = listSkills()
202
+ const skills = slugs.map(s => {
203
+ const skill = readSkillMd(s)
204
+ return {
205
+ slug: s,
206
+ name: skill?.meta?.name ?? s,
207
+ description: skill?.meta?.description ?? '',
208
+ layer: skill?.meta?.layer ?? 'domain',
209
+ score: skill?.meta?.score ?? 0.7,
210
+ generation: skill?.meta?.generation ?? 0,
211
+ tags: skill?.meta?.tags ?? [],
212
+ }
213
+ })
214
+
215
+ return NextResponse.json({ skills })
216
+ }
217
+
218
+ // ─── PUT: Update a skill ────────────────────────────────────────
219
+
220
+ export async function PUT(request: NextRequest) {
221
+ const body = await request.json()
222
+ const { slug, content, layer } = body
223
+
224
+ if (!slug) return NextResponse.json({ error: 'slug required' }, { status: 400 })
225
+
226
+ // Promote/demote layer
227
+ if (layer && !content) {
228
+ const skill = readSkillMd(slug)
229
+ if (!skill) return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
230
+
231
+ const adaptation = analyzeNetworkAdaptation('promote', slug, layer)
232
+
233
+ // Update layer in the raw file
234
+ const updated = skill.raw.replace(
235
+ /^(layer:\s*).*$/m,
236
+ `$1${layer}`
237
+ )
238
+ // If no layer field, add it after description
239
+ const finalContent = updated.includes('layer:')
240
+ ? updated
241
+ : skill.raw.replace(/(description:.*\n)/, `$1layer: ${layer}\n`)
242
+
243
+ const skillPath = join(GENERAL_DIR, slug, 'SKILL.md')
244
+ writeFileSync(skillPath, finalContent)
245
+
246
+ return NextResponse.json({ success: true, slug, layer, adaptation })
247
+ }
248
+
249
+ // Update content
250
+ if (content) {
251
+ const adaptation = analyzeNetworkAdaptation('update', slug)
252
+ const skillDir = join(GENERAL_DIR, slug)
253
+ if (!existsSync(skillDir)) mkdirSync(skillDir, { recursive: true })
254
+ writeFileSync(join(skillDir, 'SKILL.md'), content)
255
+
256
+ return NextResponse.json({ success: true, slug, adaptation })
257
+ }
258
+
259
+ return NextResponse.json({ error: 'content or layer required' }, { status: 400 })
260
+ }
261
+
262
+ // ─── POST: Create a new skill ───────────────────────────────────
263
+
264
+ export async function POST(request: NextRequest) {
265
+ const body = await request.json()
266
+ const { slug, content } = body
267
+
268
+ if (!slug || !content) {
269
+ return NextResponse.json({ error: 'slug and content required' }, { status: 400 })
270
+ }
271
+
272
+ const adaptation = analyzeNetworkAdaptation('create', slug)
273
+
274
+ const skillDir = join(GENERAL_DIR, slug)
275
+ mkdirSync(skillDir, { recursive: true })
276
+ writeFileSync(join(skillDir, 'SKILL.md'), content)
277
+
278
+ return NextResponse.json({ success: true, slug, adaptation })
279
+ }
280
+
281
+ // ─── DELETE: Remove a skill ─────────────────────────────────────
282
+
283
+ export async function DELETE(request: NextRequest) {
284
+ const slug = request.nextUrl.searchParams.get('slug')
285
+ if (!slug) return NextResponse.json({ error: 'slug required' }, { status: 400 })
286
+
287
+ const skillDir = join(GENERAL_DIR, slug)
288
+ if (!existsSync(skillDir)) {
289
+ return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
290
+ }
291
+
292
+ // Analyze impact BEFORE deleting
293
+ const adaptation = analyzeNetworkAdaptation('delete', slug)
294
+
295
+ rmSync(skillDir, { recursive: true })
296
+
297
+ return NextResponse.json({ success: true, slug, adaptation })
298
+ }
@@ -0,0 +1,128 @@
1
+ import { loadHistory } from '@/lib/data'
2
+
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ export default function EvolutionPage() {
6
+ const history = loadHistory()
7
+ const iterations = [...history.iterations].reverse()
8
+ const total = iterations.flatMap(i => i.proposals)
9
+ const accepted = total.filter(p => p.outcome === 'accepted').length
10
+ const rejected = total.filter(p => p.outcome === 'rejected').length
11
+
12
+ return (
13
+ <div>
14
+ <div className="page-header">
15
+ <h1 className="page-title">Evolution Timeline</h1>
16
+ <p className="page-desc">
17
+ {iterations.length} runs · {accepted} accepted · {rejected} rejected
18
+ </p>
19
+ </div>
20
+
21
+ {/* Summary Stats */}
22
+ <div className="grid-3" style={{ marginBottom: 28 }}>
23
+ <div className="stat-card">
24
+ <div className="stat-value" style={{ color: 'var(--purple)' }}>{iterations.length}</div>
25
+ <div className="stat-label">Evolution Runs</div>
26
+ </div>
27
+ <div className="stat-card">
28
+ <div className="stat-value" style={{ color: 'var(--green)' }}>{accepted}</div>
29
+ <div className="stat-label">Proposals Accepted</div>
30
+ </div>
31
+ <div className="stat-card">
32
+ <div className="stat-value" style={{ color: 'var(--red)' }}>{rejected}</div>
33
+ <div className="stat-label">Proposals Rejected</div>
34
+ </div>
35
+ </div>
36
+
37
+ {/* Timeline */}
38
+ <div style={{ paddingLeft: 20, position: 'relative' }}>
39
+ <div style={{ position: 'absolute', left: 9, top: 0, bottom: 0, width: 2, background: 'var(--border)' }} />
40
+
41
+ {iterations.map(iter => (
42
+ <div key={iter.id} style={{ position: 'relative', marginBottom: 24, paddingLeft: 28 }}>
43
+ {/* Timeline dot */}
44
+ <div style={{
45
+ position: 'absolute', left: -1, top: 4, width: 14, height: 14,
46
+ borderRadius: '50%', background: 'var(--purple)', border: '3px solid var(--bg)',
47
+ boxShadow: '0 0 0 2px var(--purple-light)',
48
+ }} />
49
+
50
+ {/* Run Header */}
51
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 10 }}>
52
+ <span style={{ fontSize: 15, fontWeight: 700 }}>{iter.id}</span>
53
+ <span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
54
+ {new Date(iter.timestamp).toLocaleString()} · {iter.trigger} · {iter.failureCount} failures
55
+ </span>
56
+ </div>
57
+
58
+ {/* Clusters */}
59
+ {iter.failureClusters.length > 0 && (
60
+ <div style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap' }}>
61
+ {iter.failureClusters.map((c, i) => (
62
+ <span key={i} className="badge badge-blue">{c.type} ({c.count})</span>
63
+ ))}
64
+ </div>
65
+ )}
66
+
67
+ {/* Proposals */}
68
+ {iter.proposals.map(p => (
69
+ <div key={p.id} className="proposal-card" style={{
70
+ borderLeft: `3px solid ${p.outcome === 'accepted' ? 'var(--green)' : 'var(--red)'}`,
71
+ }}>
72
+ <div style={{ padding: '12px 16px' }}>
73
+ {/* Proposal Header */}
74
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
75
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
76
+ <span className={`badge ${p.outcome === 'accepted' ? 'badge-green' : 'badge-red'}`}>
77
+ {p.outcome}
78
+ </span>
79
+ <span style={{ fontSize: 14, fontWeight: 600 }}>{p.targetSkill}</span>
80
+ <span className="badge badge-gray">{p.action}</span>
81
+ </div>
82
+ </div>
83
+
84
+ {/* Description */}
85
+ <p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 10, lineHeight: 1.5 }}>
86
+ {p.description.slice(0, 200)}
87
+ </p>
88
+
89
+ {/* Judge Scores */}
90
+ <div style={{ display: 'flex', gap: 10, marginBottom: 6 }}>
91
+ {[
92
+ { label: 'Task', score: p.judges.taskCompletion.score, color: 'var(--green)' },
93
+ { label: 'Align', score: p.judges.correctionAlignment.score, color: 'var(--blue)' },
94
+ { label: 'Side-effects', score: p.judges.sideEffectCheck.score, color: 'var(--purple)' },
95
+ ].map(j => (
96
+ <div key={j.label} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
97
+ <span style={{ color: 'var(--text-dim)' }}>{j.label}:</span>
98
+ <b style={{ color: j.score >= 7 ? j.color : 'var(--red)' }}>{j.score}/10</b>
99
+ </div>
100
+ ))}
101
+ {p.regressionResult.total > 0 && (
102
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
103
+ <span style={{ color: 'var(--text-dim)' }}>Regression:</span>
104
+ <b>{p.regressionResult.passed}/{p.regressionResult.total}</b>
105
+ </div>
106
+ )}
107
+ </div>
108
+
109
+ {/* Outcome Reason */}
110
+ <div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.4 }}>{p.outcomeReason}</div>
111
+ </div>
112
+ </div>
113
+ ))}
114
+ </div>
115
+ ))}
116
+
117
+ {iterations.length === 0 && (
118
+ <div className="card" style={{ marginLeft: 28 }}>
119
+ <div className="empty-state">
120
+ <div className="empty-state-title">No evolution runs yet</div>
121
+ <div className="empty-state-desc">Run <code>helixevo evolve</code> to start the evolution loop</div>
122
+ </div>
123
+ </div>
124
+ )}
125
+ </div>
126
+ </div>
127
+ )
128
+ }
@@ -0,0 +1,128 @@
1
+ import { loadFrontier, loadCanaries } from '@/lib/data'
2
+
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ export default function FrontierPage() {
6
+ const frontier = loadFrontier()
7
+ const canaries = loadCanaries()
8
+
9
+ return (
10
+ <div>
11
+ <div className="page-header">
12
+ <h1 className="page-title">Pareto Frontier</h1>
13
+ <p className="page-desc">
14
+ Top {frontier.capacity} skill configurations ranked by composite score
15
+ </p>
16
+ </div>
17
+
18
+ {/* Frontier Programs */}
19
+ <div style={{ display: 'grid', gap: 12, marginBottom: 28 }}>
20
+ {frontier.programs.map((p, rank) => (
21
+ <div key={`${p.id}-${rank}`} className="card" style={{ overflow: 'hidden' }}>
22
+ <div className="card-body" style={{ position: 'relative' }}>
23
+ {/* Rank Badge */}
24
+ <div style={{
25
+ position: 'absolute', top: 0, right: 0,
26
+ padding: '4px 16px',
27
+ background: rank === 0 ? 'var(--purple)' : rank === 1 ? 'var(--blue)' : 'var(--bg-section)',
28
+ color: rank <= 1 ? '#fff' : 'var(--text-dim)',
29
+ borderBottomLeftRadius: 10, fontSize: 12, fontWeight: 700,
30
+ }}>
31
+ #{rank + 1}
32
+ </div>
33
+
34
+ {/* Header */}
35
+ <div style={{ marginBottom: 16 }}>
36
+ <div style={{ fontSize: 17, fontWeight: 700 }}>{p.id}</div>
37
+ <div style={{ fontSize: 12, color: 'var(--text-dim)', marginTop: 2 }}>
38
+ gen {p.generation} · {new Date(p.createdAt).toLocaleDateString()}
39
+ </div>
40
+ </div>
41
+
42
+ {/* Score Grid */}
43
+ <div className="grid-4" style={{ marginBottom: 16 }}>
44
+ {[
45
+ { label: 'Composite', value: p.score, color: 'var(--purple)' },
46
+ { label: 'Task', value: p.scores.taskCompletion, color: 'var(--green)' },
47
+ { label: 'Alignment', value: p.scores.correctionAlignment, color: 'var(--blue)' },
48
+ { label: 'Side-Effect Free', value: p.scores.sideEffectFree, color: 'var(--yellow)' },
49
+ ].map(s => (
50
+ <div key={s.label} style={{
51
+ padding: '10px 12px',
52
+ background: 'var(--bg-section)',
53
+ borderRadius: 10,
54
+ }}>
55
+ <div style={{
56
+ fontSize: 9, color: 'var(--text-muted)', textTransform: 'uppercase',
57
+ letterSpacing: 1, marginBottom: 4, fontWeight: 600,
58
+ }}>{s.label}</div>
59
+ <div style={{
60
+ fontSize: 22, fontWeight: 800, lineHeight: 1,
61
+ color: s.value >= 0.8 ? 'var(--green)' : s.value >= 0.6 ? 'var(--yellow)' : 'var(--red)',
62
+ }}>
63
+ {(s.value * 100).toFixed(0)}
64
+ </div>
65
+ <div className="score-track" style={{ height: 4, marginTop: 6 }}>
66
+ <div className="score-fill" style={{
67
+ width: `${s.value * 100}%`,
68
+ background: s.value >= 0.8 ? 'var(--green)' : s.value >= 0.6 ? 'var(--yellow)' : 'var(--red)',
69
+ }} />
70
+ </div>
71
+ </div>
72
+ ))}
73
+ </div>
74
+
75
+ {/* Description */}
76
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
77
+ {p.changesDescription.slice(0, 200)}
78
+ </div>
79
+ </div>
80
+ </div>
81
+ ))}
82
+
83
+ {frontier.programs.length === 0 && (
84
+ <div className="card">
85
+ <div className="empty-state">
86
+ <div className="empty-state-title">Frontier empty</div>
87
+ <div className="empty-state-desc">Run <code>helixevo evolve</code> to populate the frontier</div>
88
+ </div>
89
+ </div>
90
+ )}
91
+ </div>
92
+
93
+ {/* Canaries */}
94
+ <div className="card">
95
+ <div className="card-body">
96
+ <div className="card-header-label">Active Canaries</div>
97
+ {canaries.entries.length > 0 ? (
98
+ canaries.entries.map((c, i) => {
99
+ const daysLeft = Math.max(0, Math.ceil((new Date(c.expiresAt).getTime() - Date.now()) / (24 * 60 * 60 * 1000)))
100
+ return (
101
+ <div key={i} style={{
102
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
103
+ padding: '10px 0', borderBottom: i < canaries.entries.length - 1 ? '1px solid var(--border-subtle)' : 'none',
104
+ }}>
105
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
106
+ <div style={{
107
+ width: 8, height: 8, borderRadius: '50%',
108
+ background: daysLeft > 0 ? 'var(--yellow)' : 'var(--green)',
109
+ }} />
110
+ <span style={{ fontWeight: 500, fontSize: 13 }}>{c.skillSlug}</span>
111
+ <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>{c.version}</span>
112
+ </div>
113
+ <span className={`badge ${daysLeft > 0 ? 'badge-yellow' : 'badge-green'}`}>
114
+ {daysLeft > 0 ? `${daysLeft}d remaining` : 'Ready to promote'}
115
+ </span>
116
+ </div>
117
+ )
118
+ })
119
+ ) : (
120
+ <div style={{ color: 'var(--text-dim)', fontSize: 13, textAlign: 'center', padding: 20 }}>
121
+ No active canaries
122
+ </div>
123
+ )}
124
+ </div>
125
+ </div>
126
+ </div>
127
+ )
128
+ }