CTI-Transmute — Developer Documentation
Complete reference for the CTI conversion platform — architecture, database models, REST API, web routes, and frontend patterns.
Flask 3 Vue 3 (no build) PostgreSQL Pivotick SQLAlchemy + Flask-Migrate Flask-Login + WTForms

Overview

CTI-Transmute is a web platform that converts threat intelligence data between MISP and STIX 2.x formats. The conversion engine is Pivotick, a separate service running on port 6868. The web application wraps it with persistent storage, social features, a tagging system, and interactive visualizations.

Flask 3
Backend framework, blueprints, routes
Vue 3
CDN global build, no compile step
PostgreSQL
SQLAlchemy ORM, Flask-Migrate
Pivotick
Conversion engine, git submodule
ECharts
Sunburst & treemap charts
Bootstrap 5.3
+ FontAwesome 6.3 + hljs 11.8
Two conversion directions
MISP → STIX 2.1 and STIX → MISP. Each result is stored and versioned.
Social layer
Comments, reactions, follow system, notifications, and community reporting.
Tag system
12 380+ MISP taxonomy tags, galaxy tags, and custom user tags with admin approval.
Evaluation
Per-conversion quality scoring with like/dislike, reaction tags, and PDF/Markdown export.

Quick Start

All commands are run via uv from the repo root.

First-time setup
# Clone with submodules
git clone --recurse-submodules <repo>

# First-time: init submodules + create DB + apply migrations
uv run manage init

# Start everything
uv run manage start
Day-to-day commands
uv run manage start        # start the app
uv run manage update       # git pull + deps + migrations
uv run manage backup       # pg_dump to website/db_class/backups/
uv run manage db migrate   # create a new migration
uv run manage db upgrade   # apply pending migrations
Configuration

Edit config/generic.json:

config/generic.json
{
    "listen_ip": "0.0.0.0",
    "listen_port": 5000
}

Database connection is hardcoded in website/web/__init__.py:

postgresql+psycopg2://cti_user:cti_pass@localhost:5432/cti_db

Architecture

Browser
Vue 3 + Bootstrap
Flask App
:5000
PostgreSQL
:5432
Pivotick
:6868 (internal)
Blueprint structure
Each feature is a folder with 3 files: feature.py (routes), feature_core.py (DB/logic), feature_form.py (WTForms).
Entry point
bin/start_website.py registers all blueprints and calls application.run().
Conversion flow
Flask receives JSON → proxies to Pivotick at :6868 → stores result in DB → returns to Vue.
Submodules
submodules/pivotick, submodules/misp-taxonomies, submodules/misp-galaxy — all git submodules.
Project layout
bin/
  manage.py              # CLI: start / init / update / backup / db
  start_website.py       # registers blueprints → application.run()
config/
  generic.json           # listen_ip, listen_port
cti_transmute/           # Transmute class wrapper
submodules/              # git submodules
website/
  web/__init__.py        # Flask factory, db/login/session/migrate
  web/home.py            # home_blueprint
  web/conversions/           # conversion blueprint + core + form
  web/evaluate/          # evaluate blueprint + core
  web/tags/              # tags blueprint + core
  web/account/           # account blueprint
  web/utils.py           # shared helpers
  web/templates/         # Jinja2 (base.html + per-feature folders)
  web/static/
    css/                 # one .css per feature
    js/graph/            # conversion visualizations
    js/misp/             # MISP push modal
    js/tags/             # tag components
    js/evaluate/         # evaluation charts
  db_class/db.py         # all SQLAlchemy models
  migrations/            # Flask-Migrate / Alembic
  api/conversions.py         # REST API blueprint (:6868)

Database Models

All models live in website/db_class/db.py. Every model exposes a to_json() method. Soft deletes use is_active = False + optional deleted_at. Every main model has a uuid column (String 36).

User
User Authentication, roles, API access
FieldTypeNotes
idInteger PKAuto-increment primary key
first_name / last_nameString(64)Display name
emailString(64)Unique, used for login
password_hashString(165)Hash only — set via the write-only password property
adminBooleanAdmin flag
api_keyString(60)REST API key, sent in the X-API-KEY header
is_connectedBooleanOnline status flag
Conversion
Conversion Core conversion record
FieldTypeNotes
id / uuidInteger / String(36)PK + UUID4
nameString(255)User-defined label
descriptionTextOptional free text
source_format / target_formatString(50)Converter slugs, e.g. "misp" / "stix"
paramsJSONParams the conversion ran with (null for legacy rows)
input_text / output_textTextRaw JSON strings
publicBooleanVisibility flag
share_keyString(36)Token for sharing private conversions
is_activeBooleanSoft-delete flag
user_idFK → UserOwner
created_at / updated_at / deleted_atDateTimeUTC timestamps
ConversionHistory Version tracking — a snapshot before reconversion
FieldTypeNotes
versionIntegerAuto-incremented per conversion
statusString(20)"pending" | "accepted" | "rejected"
input_textTextInput snapshot the refresh ran on
old_output_text / new_output_textTextOutput before and after the refresh — the diff's two sides
params / publicJSON / BooleanSnapshot of the conversion's params and visibility
commentTextOptional change note
conversion_idFK → ConversionParent conversion
Comments & Reactions
Comment Discussion thread + evaluation comments on a conversion
FieldTypeNotes
contentTextMax 2000 chars
is_privateBooleanOnly visible to author + admins
is_evaluationBooleanShows under Evaluation tab instead of Discussion
is_deletedBooleanSoft-delete, content replaced with placeholder
parent_idFK → CommentSelf-referential for replies (1 level)
conversion_id / user_idFKOwner and target
Tags
Tag Taxonomy / Galaxy / Manual tags with admin approval
FieldTypeNotes
nameTextUnique tag name (e.g. tlp:red)
colorString(50)Hex color code
iconString(50)FontAwesome class name
sourceString(255)Tag origin: "Taxonomy", "Galaxy", "Vulnerability", manual
is_approved_by_admin / is_activeBooleanAdmin approval workflow
visibilityString(255)Visibility scope for non-admin users
is_evaluation_tagBooleanAvailable as reaction in evaluation voting
ConversionTagAssociation Bridge: many tags ↔ many conversions
FieldTypeNotes
conversion_id / tag_idFKComposite unique constraint
user_id / added_atFK → User / DateTimeWho attached the tag, and when
source_typeString(10)"user" or "json" (auto-extracted from content)
Social & Evaluation
ConversionEvaluation Like / dislike / reaction votes per conversion
FieldTypeNotes
eval_typeString(20)"like" | "dislike" | "reaction"
reaction_keyString(50)Tag name used as evaluation reaction
conversion_id / user_idFKUnique per (user, conversion, eval_type)
Notification In-app notifications for social events
FieldTypeNotes
typeString(50)comment_reply, new_follow_conversion, report_submitted, …
is_readBooleanUnread badge count in navbar
related_id / related_typeInteger / String(50)Polymorphic target (conversion, comment, …)
actor_id / messageInteger / TextWho triggered it, and the rendered text
user_idFK → UserRecipient
UserFollow / ConversionFavorite / ConversionReport / SystemLog / GraphConfig / PlatformReview
ModelPurpose
UserFollowfollower_id → followed_id social graph
ConversionFavoriteBookmarked conversions per user
ConversionReportAbuse reports: reason + description + status (pending / reviewed / dismissed)
SystemLogAudit log — event_type, actor_name, target_id/type, details, created_at
GraphConfigSaved Pivotick visualization configs per user
PlatformReview1–5 star platform rating + optional text per user

REST API

The REST API is part of the same Flask app as the web UI, on port 6868, under /api/convert/....

Authentication is optional: send your api_key in the X-API-KEY header to act as your account. Without the header the call is anonymous; a wrong key is rejected with 403 rather than treated as anonymous.
List available converters
GET
/api/convert/list

Returns the list of all available conversion directions and their parameters.

Public
Response
{
    "available": {
        "/api/convert/misp_to_stix": {
            "source": "misp",
            "target": "stix",
            "method": "POST",
            "description": "Convert a MISP data collection to STIX format.",
            "params_schema": {
                "additionalProperties": false,
                "properties": {
                    "version": {
                        "default": "2.1",
                        "description": "STIX specification version of the generated bundle.",
                        "enum": ["2.0", "2.1"],
                        "title": "STIX Version",
                        "type": "string"
                    }
                },
                "title": "MispToStixParams",
                "type": "object"
            }
        },
        "/api/convert/stix_to_misp": {
            "source": "stix",
            "target": "misp",
            "method": "POST",
            "description": "Convert a STIX data collection to MISP format.",
            "params_schema": {
                "additionalProperties": false,
                "properties": { "distribution": { "..." : "..." }, "galaxies_as_tags": { "..." : "..." }, "..." : "..." },
                "title": "StixToMispParams",
                "type": "object"
            }
        }
    }
}
MISP → STIX
POST
/api/convert/misp_to_stix?version=2.1

Convert a MISP JSON event to a STIX 2.1 bundle. Body is the raw MISP JSON.

Public · key optional
Request body (MISP event)
{
    "Event": {
        "info": "Phishing campaign targeting finance sector",
        "uuid": "5e9f1234-1234-1234-1234-1234567890ab",
        "date": "2024-01-15",
        "threat_level_id": "2",
        "Attribute": [
            {
                "category": "Network activity",
                "type": "ip-dst",
                "value": "198.51.100.42",
                "to_ids": true,
                "uuid": "5e9f1234-aaaa-bbbb-cccc-1234567890ab"
            },
            {
                "category": "Payload delivery",
                "type": "sha256",
                "value": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
                "to_ids": true
            }
        ],
        "Tag": [
            { "name": "tlp:amber" }
        ]
    }
}
Response (STIX bundle)
{
    "type": "bundle",
    "id": "bundle--a1b2c3d4-1234-5678-abcd-ef0123456789",
    "objects": [
        {
            "type": "report",
            "id": "report--...",
            "name": "Phishing campaign targeting finance sector",
            "published": "2024-01-15T00:00:00Z",
            "object_refs": ["indicator--...", "marking-definition--..."]
        },
        {
            "type": "indicator",
            "id": "indicator--...",
            "pattern": "[ipv4-addr:value = '198.51.100.42']",
            "pattern_type": "stix",
            "valid_from": "2024-01-15T00:00:00Z"
        },
        {
            "type": "marking-definition",
            "id": "marking-definition--...",
            "definition_type": "tlp",
            "definition": { "tlp": "amber" }
        }
    ]
}
STIX → MISP
POST
/api/convert/stix_to_misp

Convert a STIX 2.x bundle to a MISP event. All params are query strings.

Public · key optional
ParameterTypeDefaultDescription
distributionint 0–40MISP distribution level (0=org, 1=community, 2=connected, 3=all, 4=sharing group)
sharing_group_idintRequired when distribution=4
galaxies_as_tagsflagoffConvert STIX galaxies to MISP tags instead of galaxy clusters
no_force_contextual_dataflagoffDisable forced contextual data attachment
cluster_distributionint 0–40Distribution for galaxy clusters
organisation_uuidstringUUID of the organisation to attach the event to
single_eventflagoffForce a single MISP event output
producerstringProducer name tag
titlestringOverride the event title
Response (MISP event)
{
    "Event": {
        "info": "STIX Report: ...",
        "uuid": "...",
        "distribution": 0,
        "threat_level_id": 2,
        "Attribute": [ { "type": "ip-dst", "value": "198.51.100.42", "..." : "..." } ],
        "Object": [],
        "Tag": []
    }
}
Saving the result — ?persist=true

Both convert routes are stateless by default: the response body is the converted document and nothing is saved. Add ?persist=true to save the result as a Conversion with the full catalogue treatment (History, refresh/diff, Share link, comments) — owned by your account when the call carries an X-API-KEY header, saved anonymously otherwise. The response is then an envelope instead of the bare document:

Response (persisted)
{
    "conversion": { "type": "bundle", "id": "bundle--...", "objects": [ "..." ] },
    "id": 42,
    "uuid": "5bf21cfe-487e-431d-9514-773a9365cde1",
    "url": "/conversions/42"
}

Web Routes — Conversions

All routes under /conversions. JSON routes return {"success": bool, "message": "...", "toast_class": "..."}.

Standard JSON response shape
{ "success": true,  "data": { }, "message": "Done", "toast_class": "success" }
{ "success": false, "message": "Error text",       "toast_class": "danger"  }
Conversion
POST
/conversions/misp_to_stix

Submit a MISP JSON for conversion. JSON body: payload, params (schema-driven, e.g. version), name, description, public, tag_ids.

Public
POST
/conversions/stix_to_misp

Submit a STIX bundle. Same JSON body; params carries the STIX→MISP options (distribution, galaxies_as_tags, …).

Public
POST
/conversions/fetch_misp_event

Fetch MISP events from a remote MISP instance via restSearch. Body: misp_url, api_key, event_id (or event_ids).

Public
POST
/conversions/misp_search_events

Search events on a remote MISP by info/tag/date. Returns a list of matching events.

Public
GET
/conversions/refresh/<uuid>

Re-run a conversion with the original input and show a diff preview.

Auth
POST
/conversions/history/<history_id>/accept

Accept a pending refresh (owner-or-admin) — adopts its output.

Auth
POST
/conversions/history/<history_id>/reject

Reject a pending refresh (owner-or-admin) — leaves the Conversion as-is.

Auth
History & browsing
GET
/conversions/history

History listing page (Vue SPA shell).

Public
GET
/conversions/get_conversion_page_history

Paginated JSON list. Filters: type, sort, date_from/to, tags[], favorites_only, search, search_scope.

Public
GET
/conversions/get_conversion?id=<id>

Get one conversion by DB id or UUID. Returns full object including input/output text.

Public
GET
/conversions/detail/<id>

Detail page with all tabs (Input, Output, Graph, Sunburst, Table, Comments, Evaluation).

Public
GET
/conversions/difference/<id>

Side-by-side diff of a history version vs the current output.

Auth
GET
/conversions/search_in_content?q=<q>

Full-text search with snippet extraction. Returns matching conversion IDs + context snippets.

Public
Sharing & visibility
POST
/conversions/edit_public?id=<id>

Toggle public/private. Returns new state.

Auth
GET
/conversions/get_share_key?id=<id>

Retrieve the share token for a private conversion.

Owner
POST
/conversions/regenerate_share_key?id=<id>

Rotate the share key, invalidating old share links.

Owner
GET
/conversions/share?uuid=<u>&share_key=<k>

Access a private conversion via share link. Redirects to detail if valid.

Public
POST
/conversions/delete_item

Soft-delete. Body: {"id": 42}. Moves to admin trash.

Owner
Comments & reactions
GET
/conversions/get_comments?conversion_id=<id>

Fetch all comments for a conversion. Filters private/deleted based on requester role.

Public
POST
/conversions/comment

Create a comment or reply. Body: conversion_id, content, is_private, is_evaluation, optional parent_id.

Auth
POST
/conversions/edit_comment

Update comment content. Body: comment_id, content.

Auth
DELETE
/conversions/delete_comment?comment_id=<id>

Soft-delete a comment. Content replaced with placeholder.

Auth
POST
/conversions/toggle_comment_private?comment_id=<id>

Toggle a comment between public and private.

Auth
POST
/conversions/react

Toggle emoji reaction. Body: comment_id, emoji. Whitelist: 👍 😊 ❤️ 🎯 ⚠️

Auth
Favorites, downloads & MISP push
POST
/conversions/favorite/toggle

Bookmark or un-bookmark a conversion. Body: {"conversion_id": 42}.

Auth
GET
/conversions/favorite/status/<id>

Check whether the current user has favorited a conversion.

Auth
GET
/conversions/download/<id>/input

Download the raw input JSON file.

Public
GET
/conversions/download/<id>/output

Download the conversion result JSON.

Public
GET
/conversions/download/<id>/misp-push

Download the full PyMISP push payload including evaluation tags object.

Auth
POST
/conversions/push_to_misp

Push to a remote MISP. Body: misp_url, misp_key, conversion_id.

Auth
POST
/conversions/report

Submit an abuse report. Body: conversion_id, reason (spam/inappropriate/inaccurate/other), description.

Auth
Admin — trash & moderation
GET
/conversions/trash

Admin trash page listing soft-deleted conversions.

Admin
POST
/conversions/restore

Restore a soft-deleted conversion. Body: {"id": 42}.

Admin
POST
/conversions/hard_delete

Permanently delete a conversion. Irreversible.

Admin
POST
/conversions/bulk_action

Restore or hard-delete multiple conversions. Body: {"ids": [...], "action": "restore"}.

Admin
GET
/conversions/admin/get_reports

Paginated list of abuse reports with filters.

Admin
POST
/conversions/admin/review_report?report_id=<id>&status=<s>

Mark a report as reviewed or dismissed.

Admin

Web Routes — Evaluate

All routes under /evaluate. Provides quality scoring and export for conversions.

GET
/evaluate/summary/<id>

Evaluation stats for a conversion: like/dislike counts, reaction breakdown, consensus score.

Public
POST
/evaluate/toggle_like

Like a conversion. Body: {"conversion_id": 42}. Toggle: calling twice removes the like.

Auth
POST
/evaluate/toggle_dislike

Dislike a conversion. Mutually exclusive with like.

Auth
POST
/evaluate/toggle_reaction

Vote with a reaction tag. Body: {"conversion_id": 42, "reaction_key": "tlp:red"}.

Auth
GET
/evaluate/consensus_tags/<id>

Returns tags that reached the vote threshold (default ≥3). Used in MISP push payload.

Public
GET
/evaluate/export/<id>/markdown

Download evaluation report as a .md file.

Public
GET
/evaluate/export/<id>/pdf

Download evaluation report as a PDF.

Public
GET
/evaluate/global_stats

Platform-wide evaluation stats.

Public
GET
/evaluate/activity_timeline?days=<n>

Evaluation activity over the last N days (chart data).

Public
POST
/evaluate/platform_review

Submit a 1–5 star platform review. Body: {"score": 5, "content": "..."}.

Auth
GET
/evaluate/admin/list

Admin: paginated list of all evaluations with filters.

Admin

Web Routes — Tags

All routes under /tags. Tags have three sources: Manual, Taxonomy (~12 380 from MISP), and Vulnerability.

User-facing
POST
/tags/create

Create a custom tag. Auto-approved for admins creating Vulnerability tags.

Auth
GET
/tags/list

Paginated list of tags accessible to the current user.

Public
GET
/tags/available

Tags available for attachment. Filter ?evaluation=1 for eval-only tags.

Public
GET
/tags/for_conversion/<id>

Tags currently attached to a conversion (filtered by source_type if provided).

Public
POST
/tags/save_for_conversion/<id>

Replace all tags on a conversion. Body: {"tag_ids": [1, 2, 3]}.

Auth
GET
/tags/extract_from_conversion/<id>

Auto-extract tag names from the stored JSON and match them against the tag library.

Auth
Admin
GET
/tags/admin/list

Paginated all tags with approval workflow. Filter by source, active, approved, public.

Admin
POST
/tags/admin/approve/<id>

Toggle approval status of a tag.

Admin
POST
/tags/admin/import_taxonomies

Import all tags from submodules/misp-taxonomies submodule (~12 380 tags).

Admin
POST
/tags/admin/import_galaxies

Import cluster names from submodules/misp-galaxy submodule.

Admin
POST
/tags/admin/bulk_conversions/scan

Background job: scan all conversions and auto-assign matching tags from their JSON.

Admin
GET
/tags/admin/bulk_conversions/job/<id>

Poll the status of a background bulk-tag job.

Admin

Web Routes — Account

All routes under /account.

Authentication & profile
POST
/account/login

Login with email + password. Returns a session cookie.

Public
POST
/account/register

Create an account. Validates email uniqueness and password strength.

Public
GET
/account/logout

Destroy session and redirect to home.

Auth
POST
/account/edit

Update first/last name, email, password.

Auth
Notifications & following
GET
/account/get_notification_count

Returns unread notification count for the navbar badge.

Auth
GET
/account/get_notifications

Paginated notifications. Filter ?unread_only=1, ?search=.

Auth
GET
/account/mark_all_read

Mark all unread notifications as read.

Auth
GET
/account/follow?user_id=<id>

Follow or unfollow a user. Toggle endpoint.

Auth
GET
/account/is_following?user_id=<id>

Check if the current user follows a given user.

Auth
Admin — users & audit logs
GET
/account/get_users

Paginated user list. Filters: connection status, admin role, search.

Admin
POST
/account/edit_admin

Grant or revoke admin rights. Body: {"user_id": 5, "is_admin": true}.

Admin
POST
/account/delete/<id>

Delete a user and cascade-delete all their conversions and comments.

Admin
GET
/account/admin/get_all_notifications

Merged view of notifications + system logs. Filters: type, date range, search.

Admin
POST
/account/admin/delete_logs_bulk

Bulk-delete logs by ID list.

Admin

Frontend — Vue 3

Vue 3 is loaded as a global CDN build from /static/js/vue.global.js. There is no build step — components are plain .js ES modules with inline template strings. The Jinja2 delimiter conflict is resolved by using [[ ]] instead of {{ }} in Vue templates.

Component skeleton
myComponent.js
const MyComponent = {
    delimiters: ['[[', ']]'],   // avoid conflict with Jinja2 {{ }}
    props: {
        conversionData: { type: Object, default: null },
    },
    components: { 'json-viewer': JsonViewer },
    template: `
<div class="mc-wrapper">
    [[ conversionData?.name ]]
</div>`,
    setup(props) {
        const { ref, watch } = Vue
        // ...
        watch(() => props.conversionData, v => {
            if (v) doWork()
        }, { immediate: true })  // fires immediately on mount

        return { /* exposed to template */ }
    },
}
export default MyComponent
Mounting in a Jinja template
detail.html (script block)
import MyComponent from '/static/js/graph/myComponent.js'

const app = createApp({ delimiters: ['[[',']]'], setup() { /* ... */ } })
app.component('my-component', MyComponent)
app.mount('#main-containers')
CSRF token (for POST requests)
fetch pattern
const csrfToken = document.getElementById('csrf_token')?.value

const res = await fetch('/conversions/comment', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
    body: JSON.stringify({ conversion_id: 42, content: 'Great conversion!' }),
})
const data = await res.json()
if (data.success) create_message(data.message, data.toast_class)

Component Inventory

All reusable Vue components, their file paths, and what they do.

Component
File
Description
ConversionSunburstjs/graph/conversionSunburst.jsECharts sunburst + treemap of CTI data by category/type. Click a slice → JsonViewer drill-down.
ConversionTablejs/graph/conversionTable.jsFlat tabular view with search, sortable columns, pagination (50 rows/page). Click a row → JsonViewer.
JsonViewerjs/graph/jsonViewer.jsReusable JSON display: hljs syntax highlighting, collapsible tree, format, download, copy.
ConversionGraphjs/graph/conversionGraph.jsForce-directed graph via Pivotick. Input/Output toggle. Config modal via graphConfigModal.js.
EvaluationPaneljs/evaluationPanel.jsLike / dislike / reaction-tag voting panel per conversion.
EvaluationChartsjs/evaluate/evaluationCharts.jsAggregated vote charts for a single conversion's evaluation tab.
PushConversionToMISPjs/misp/pushConversionToMISP.jsModal: configure MISP URL + key and push the conversion with evaluation tags.
TagInputjs/tags/tagInput.jsTag selector + display per conversion. Uses Select2-style dropdown with search.
Detail page tab system

The detail page (templates/conversions/detail.html) uses Bootstrap 5 tabs. Each tab is a nav-link + a matching tab-pane. The visualization components receive the conversion object as :conversion-data="conversion" and auto-render when the prop becomes available via watch({ immediate: true }).

Tab
Pane ID pattern
Lazy?
Input#input-pane-{id}hljs-highlighted JSON, Tree toggle, Format, Download, Copy
Output#output-pane-{id}Same as Input for the conversion result
History#compare-pane-{id}Version table, click row → /conversions/difference/{id}
Graph#graph-pane-{id}Pivotick force-directed graph, loaded on tab click
Sunburst#sunburst-pane-{id}<conversion-sunburst> — ECharts, lazy CDN load
Table#table-pane-{id}<conversion-table> — pure JS parse + render
Comments#comments-pane-{id}Discussion thread, loaded on first tab click
Evaluation#eval-pane-{id}EvaluationPanel + EvaluationCharts + eval comments

Adding a New Visualization

All visualization components follow the same pattern. They live in website/web/static/js/graph/, receive conversionData as a prop, and parse JSON client-side — no extra API call needed.

1
Create the component file
Create website/web/static/js/graph/myViz.js. The component receives conversionData (the full conversion object with input_text and output_text fields). Use watch({ immediate: true }) so it auto-renders as soon as the prop is populated — even before the user clicks the tab.
2
Detect the format and parse
Use the shared detection pattern. MISP JSON contains "Event" or "Attribute". STIX bundles contain "type":"bundle". Parse synchronously, defer large work with setTimeout(fn, 0) so the spinner renders first.
3
Lazy-load any external library
Use the singleton promise pattern — store the promise in a module-level variable so the CDN script is only appended once regardless of how many times the component mounts or renders.
4
Handle resize and theme changes
Listen to window resize and document.documentElement themechange. Use a ResizeObserver on the chart container to handle the case where the chart initialised while the tab was hidden (zero dimensions).
5
Add the tab in detail.html
Add a <li class="nav-item"> with data-bs-toggle="tab" pointing to #myviz-pane-[[conversion.id]]. Add the matching tab-pane below. Place <my-viz :conversion-data="conversion"></my-viz> inside it. Import and register the component in the <script type="module"> block. Add CSS with a unique class prefix to detail.css.
Minimal visualization template
import JsonViewer from '/static/js/graph/jsonViewer.js'

// Singleton CDN loader
let _libPromise = null
function loadLib() {
    if (window.myLib) return Promise.resolve(window.myLib)
    if (_libPromise)  return _libPromise
    _libPromise = new Promise((resolve, reject) => {
        const s = document.createElement('script')
        s.src = 'https://cdn.example.com/mylib.min.js'
        s.onload  = () => resolve(window.myLib)
        s.onerror = () => reject(new Error('CDN load failed'))
        document.head.appendChild(s)
    })
    return _libPromise
}

function detectFormat(text) {
    if (!text) return 'unknown'
    const s = text.trimStart()
    if (s.includes('"type":"bundle"') || s.includes('"type": "bundle"')) return 'stix'
    if (s.includes('"Event"') || s.includes('"Attribute"')) return 'misp'
    return 'unknown'
}

const MyViz = {
    delimiters: ['[[', ']]'],
    components: { 'json-viewer': JsonViewer },
    props: { conversionData: { type: Object, default: null } },
    template: `
<div class="mvz-wrapper">
    <div class="mvz-toolbar">
        <button :class="{active: side==='input'}"  @click="setSide('input')">Input</button>
        <button :class="{active: side==='output'}" @click="setSide('output')">Output</button>
    </div>
    <div v-if="loading"><!-- spinner --></div>
    <div v-else-if="error">[[ error ]]</div>
    <div v-else ref="chartEl" class="mvz-chart"></div>
</div>`,

    setup(props) {
        const { ref, watch, onUnmounted } = Vue
        const side    = ref('input')
        const loading = ref(false)
        const error   = ref('')
        const chartEl = ref(null)
        let inst = null, resizeObs = null

        async function render() {
            if (!props.conversionData) return
            loading.value = true
            error.value = ''
            const text = side.value === 'input'
                ? props.conversionData.input_text
                : props.conversionData.output_text
            if (!text) { loading.value = false; return }

            const fmt = detectFormat(text)
            // parse...

            try { await loadLib() } catch { error.value = 'CDN load failed'; loading.value = false; return }
            loading.value = false
            await Vue.nextTick()

            // init chart in chartEl.value...

            if (resizeObs) resizeObs.disconnect()
            resizeObs = new ResizeObserver(() => { if (inst && chartEl.value?.offsetWidth > 0) inst.resize() })
            resizeObs.observe(chartEl.value)
        }

        function setSide(s) { side.value = s; render() }

        window.addEventListener('resize', () => inst?.resize())
        document.documentElement.addEventListener('themechange', render)
        onUnmounted(() => {
            window.removeEventListener('resize', () => inst?.resize())
            document.documentElement.removeEventListener('themechange', render)
            resizeObs?.disconnect()
            inst?.dispose?.()
        })

        watch(() => props.conversionData, v => { if (v?.input_text || v?.output_text) render() }, { immediate: true })
        return { side, loading, error, chartEl, setSide }
    },
}
export default MyViz

CSS & Theming

The app supports 4 themes — light, dark, dusk, ocean — applied as a class on <html> (e.g. html.dark-mode). All color values are CSS custom properties defined in core.css.

Core design tokens
--bgPage background
--surfaceCard / panel background
--surface-2Secondary surface (table headers, toolbars)
--borderDefault border color
--accentPrimary interactive color (blue)
--accent-lightFaint accent background (hover states)
--textPrimary text
--text-2Secondary / muted text
--text-3Disabled / placeholder text
--success / --danger / --warningSemantic colors
--font-uiInter (UI text)
--font-monoJetBrains Mono (code)
--radius-sm / --radius-md / --radius-lg6px / 10px / 14px
--shadow-sm / --shadow-md / --shadow-lgElevation shadows
--tTransition shorthand — 0.18s ease
Theme change event

When the user switches theme, the app dispatches a custom event on document.documentElement. Use this to re-render charts or swap hljs themes.

Listen for theme change
document.documentElement.addEventListener('themechange', (e) => {
    const theme = e.detail?.theme  // 'light' | 'dark' | 'dusk' | 'ocean'
    const isDark = !['light', 'ocean'].includes(theme)
    // re-render charts, swap hljs stylesheet, etc.
})
CSS class prefix convention

Each Vue component uses a unique 3–4 character prefix for all its CSS classes to prevent collisions with Bootstrap and other components.

Prefix
Component
File
csb-ConversionSunburstdetail.css
ctbl-ConversionTabledetail.css
jv-JsonViewerdetail.css
jt-JSON Tree (inline)detail.css — shared by JsonViewer + raw viewer
dc-Docs pagedocs.css