diff --git a/decnet_web/src/components/CanaryTokens.tsx b/decnet_web/src/components/CanaryTokens.tsx
index dd344874..d1a77b45 100644
--- a/decnet_web/src/components/CanaryTokens.tsx
+++ b/decnet_web/src/components/CanaryTokens.tsx
@@ -2,78 +2,20 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Plus, Upload, X, AlertTriangle, Search, Target,
} from '../icons';
-import api, { type ApiError } from '../utils/api';
+import api from '../utils/api';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { useFocusTrap } from '../hooks/useFocusTrap';
import CanaryTokenDrawer from './CanaryTokenDrawer';
import type { CanaryTokenRow } from './CanaryTokenDrawer';
-
-interface BlobRow {
- uuid: string;
- sha256: string;
- filename: string;
- content_type: string;
- size_bytes: number;
- uploaded_by: string;
- uploaded_at: string;
- token_count: number;
-}
-
-const KNOWN_GENERATORS = [
- 'git_config', 'env_file', 'ssh_key', 'aws_creds',
- 'honeydoc', 'honeydoc_docx', 'honeydoc_pdf',
-] as const;
-type GeneratorName = typeof KNOWN_GENERATORS[number];
-
-const KIND_OPTIONS: Array<{ value: 'http' | 'dns' | 'aws_passive'; label: string }> = [
- { value: 'http', label: 'HTTP callback' },
- { value: 'dns', label: 'DNS callback' },
- { value: 'aws_passive', label: 'AWS passive (no callback)' },
-];
-
-function extractError(err: unknown, fallback: string): string {
- const e = err as ApiError;
- if (e?.response?.data?.detail) return e.response.data.detail;
- if (e?.response?.status === 403) return 'Insufficient permissions (admin only).';
- if (e?.response?.status === 401) return 'Session expired — please log in again.';
- return fallback;
-}
-
-function fmt(iso: string | null): string {
- if (!iso) return '—';
- const d = new Date(iso);
- if (Number.isNaN(d.getTime())) return iso;
- const pad = (n: number) => String(n).padStart(2, '0');
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
-}
-
-function fmtBytes(n: number): string {
- if (n < 1024) return `${n} B`;
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
- return `${(n / 1024 / 1024).toFixed(1)} MiB`;
-}
-
-const STATE_COLOR = {
- planted: '#00ff88',
- revoked: 'var(--dim-color)',
- failed: '#ff5555',
-};
+import {
+ KNOWN_GENERATORS, KIND_OPTIONS, STATE_COLOR,
+ type BlobRow, type DeckyOption, type TopologyOption, type Scope, type GeneratorName,
+} from './CanaryTokens/types';
+import { extractError, fmt, fmtBytes } from './CanaryTokens/helpers';
+import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field, Stat } from './CanaryTokens/ui';
// ─── CREATE MODAL ──────────────────────────────────────────────────────────
-interface DeckyOption {
- name: string;
- ip?: string;
-}
-
-interface TopologyOption {
- id: string;
- name: string;
- status: string;
-}
-
-type Scope = 'fleet' | 'topology';
-
interface CreateModalProps {
blobs: BlobRow[];
deckies: DeckyOption[];
@@ -1275,60 +1217,4 @@ const CanaryTokens: React.FC = () => {
);
};
-// ─── small style helpers ───────────────────────────────────────────────────
-
-const INPUT_STYLE: React.CSSProperties = {
- width: '100%',
- padding: '8px 10px',
- marginBottom: '12px',
- background: 'var(--matrix-tint-5)',
- border: '1px solid var(--border-color, #30363d)',
- color: 'var(--text-color)',
- fontSize: '0.85rem',
-};
-
-const BTN_PRIMARY: React.CSSProperties = {
- padding: '8px 14px',
- border: '1px solid var(--accent-color, #00ff88)',
- background: 'var(--accent-color, #00ff88)',
- color: 'var(--bg-color, #0d1117)',
- cursor: 'pointer',
- fontSize: '0.8rem',
- textTransform: 'uppercase',
- letterSpacing: '0.05em',
- fontWeight: 'bold',
-};
-
-const BTN_GHOST: React.CSSProperties = {
- padding: '8px 14px',
- border: '1px solid var(--text-color)',
- background: 'transparent',
- color: 'var(--text-color)',
- cursor: 'pointer',
- fontSize: '0.8rem',
- textTransform: 'uppercase',
- letterSpacing: '0.05em',
-};
-
-const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
-
-
- {label.toUpperCase()}
-
- {children}
-
-);
-
-const Stat: React.FC<{ label: string; value: number | string; color: string }> = ({ label, value, color }) => (
-
-);
-
export default CanaryTokens;
diff --git a/decnet_web/src/components/CanaryTokens/helpers.ts b/decnet_web/src/components/CanaryTokens/helpers.ts
new file mode 100644
index 00000000..3435b400
--- /dev/null
+++ b/decnet_web/src/components/CanaryTokens/helpers.ts
@@ -0,0 +1,28 @@
+import type { ApiError } from '../../utils/api';
+
+/** Normalize an axios error into operator-friendly text, with role
+ * hints when the wire shape carries no detail. */
+export function extractError(err: unknown, fallback: string): string {
+ const e = err as ApiError;
+ if (e?.response?.data?.detail) return e.response.data.detail;
+ if (e?.response?.status === 403) return 'Insufficient permissions (admin only).';
+ if (e?.response?.status === 401) return 'Session expired — please log in again.';
+ return fallback;
+}
+
+/** Compact local-time YYYY-MM-DD HH:mm; returns "—" for null and the
+ * raw input back when it's not a parseable ISO string. */
+export function fmt(iso: string | null): string {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+/** B / KiB / MiB binary-prefix file-size renderer. */
+export function fmtBytes(n: number): string {
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
+ return `${(n / 1024 / 1024).toFixed(1)} MiB`;
+}
diff --git a/decnet_web/src/components/CanaryTokens/types.ts b/decnet_web/src/components/CanaryTokens/types.ts
new file mode 100644
index 00000000..533bfc6e
--- /dev/null
+++ b/decnet_web/src/components/CanaryTokens/types.ts
@@ -0,0 +1,44 @@
+/** Wire + UI types for the CanaryTokens page surface. */
+
+export interface BlobRow {
+ uuid: string;
+ sha256: string;
+ filename: string;
+ content_type: string;
+ size_bytes: number;
+ uploaded_by: string;
+ uploaded_at: string;
+ token_count: number;
+}
+
+export interface DeckyOption {
+ name: string;
+ ip?: string;
+}
+
+export interface TopologyOption {
+ id: string;
+ name: string;
+ status: string;
+}
+
+export type Scope = 'fleet' | 'topology';
+
+export const KNOWN_GENERATORS = [
+ 'git_config', 'env_file', 'ssh_key', 'aws_creds',
+ 'honeydoc', 'honeydoc_docx', 'honeydoc_pdf',
+] as const;
+
+export type GeneratorName = typeof KNOWN_GENERATORS[number];
+
+export const KIND_OPTIONS: Array<{ value: 'http' | 'dns' | 'aws_passive'; label: string }> = [
+ { value: 'http', label: 'HTTP callback' },
+ { value: 'dns', label: 'DNS callback' },
+ { value: 'aws_passive', label: 'AWS passive (no callback)' },
+];
+
+export const STATE_COLOR: Record<'planted' | 'revoked' | 'failed', string> = {
+ planted: '#00ff88',
+ revoked: 'var(--dim-color)',
+ failed: '#ff5555',
+};
diff --git a/decnet_web/src/components/CanaryTokens/ui.tsx b/decnet_web/src/components/CanaryTokens/ui.tsx
new file mode 100644
index 00000000..f51da6c2
--- /dev/null
+++ b/decnet_web/src/components/CanaryTokens/ui.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+
+export const INPUT_STYLE: React.CSSProperties = {
+ width: '100%',
+ padding: '8px 10px',
+ marginBottom: '12px',
+ background: 'var(--matrix-tint-5)',
+ border: '1px solid var(--border-color, #30363d)',
+ color: 'var(--text-color)',
+ fontSize: '0.85rem',
+};
+
+export const BTN_PRIMARY: React.CSSProperties = {
+ padding: '8px 14px',
+ border: '1px solid var(--accent-color, #00ff88)',
+ background: 'var(--accent-color, #00ff88)',
+ color: 'var(--bg-color, #0d1117)',
+ cursor: 'pointer',
+ fontSize: '0.8rem',
+ textTransform: 'uppercase',
+ letterSpacing: '0.05em',
+ fontWeight: 'bold',
+};
+
+export const BTN_GHOST: React.CSSProperties = {
+ padding: '8px 14px',
+ border: '1px solid var(--text-color)',
+ background: 'transparent',
+ color: 'var(--text-color)',
+ cursor: 'pointer',
+ fontSize: '0.8rem',
+ textTransform: 'uppercase',
+ letterSpacing: '0.05em',
+};
+
+export const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
+
+
+ {label.toUpperCase()}
+
+ {children}
+
+);
+
+export const Stat: React.FC<{ label: string; value: number | string; color: string }> = ({ label, value, color }) => (
+
+);