phase 8
This commit is contained in:
@@ -138,7 +138,7 @@ function onJobStarted(newJobId: string) {
|
||||
<!-- === BBOX EDITOR MODE === -->
|
||||
<div v-else-if="pipeline.layoutMode === 'bbox_editor'" class="editor-layout">
|
||||
<div class="editor-top">
|
||||
<SplitPane direction="horizontal" :initial-size="210" size-mode="px" :min="210" :max="350" anchor="second">
|
||||
<SplitPane direction="horizontal" :initial-size="245" size-mode="px" :min="245" :max="350" anchor="second">
|
||||
<template #first>
|
||||
<FramePanel :source="source" :status="status" :overlays="editorOverlays" :frame-image="currentFrameImage" :editor-boxes="editorBoxes" />
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ParameterEditor, useEditorExecution } from 'mpr-ui-framework'
|
||||
import type { ConfigField } from 'mpr-ui-framework'
|
||||
import {
|
||||
runEdgeDetection,
|
||||
runEdgeDetectionDebug,
|
||||
@@ -8,30 +10,13 @@ import {
|
||||
} from '@/cv'
|
||||
import type { EdgeDetectionParams } from '@/cv'
|
||||
|
||||
interface ConfigField {
|
||||
name: string
|
||||
type: string
|
||||
default: unknown
|
||||
description: string
|
||||
min: number | null
|
||||
max: number | null
|
||||
options: string[] | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Stage name (e.g. "detect_edges") */
|
||||
stage: string
|
||||
/** Job ID (used for server mode fallback) */
|
||||
jobId: string
|
||||
/** Currently displayed frame image (base64 JPEG) */
|
||||
frameImage?: string | null
|
||||
/** Currently displayed frame sequence number */
|
||||
frameRef?: number | null
|
||||
/** All checkpoint frames — when provided, Apply runs on the selection range */
|
||||
frames?: Array<{ seq: number; jpeg_b64: string }>
|
||||
/** Index into frames[] for selection start (default 0) */
|
||||
selectionStart?: number
|
||||
/** Index into frames[] for selection end (default frames.length - 1) */
|
||||
selectionEnd?: number
|
||||
}>()
|
||||
|
||||
@@ -46,14 +31,10 @@ const emit = defineEmits<{
|
||||
|
||||
const fields = ref<ConfigField[]>([])
|
||||
const values = ref<Record<string, unknown>>({})
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const regionCount = ref<number | null>(null)
|
||||
const debugEnabled = ref(true)
|
||||
const autoApply = ref(true) // auto-run on slider change (fast CV); uncheck for heavy stages
|
||||
const execMode = ref<'local' | 'server'>('local')
|
||||
const execTimeMs = ref<number | null>(null)
|
||||
const processingIndex = ref<number | null>(null) // current frame index during multi-frame run
|
||||
const processingIndex = ref<number | null>(null)
|
||||
|
||||
// Config field defaults for detect_edges (used when API is unavailable)
|
||||
const EDGE_DEFAULTS: ConfigField[] = [
|
||||
@@ -67,8 +48,27 @@ const EDGE_DEFAULTS: ConfigField[] = [
|
||||
{ name: 'edge_pair_min_distance', type: 'int', default: 15, description: 'Min pair distance (px)', min: 5, max: 200, options: null },
|
||||
]
|
||||
|
||||
// --- Execution logic (detection-specific) ---
|
||||
|
||||
async function executeDetection() {
|
||||
const hasFrames = props.frames && props.frames.length > 0
|
||||
if (!props.frameImage && !hasFrames) return
|
||||
|
||||
if (execMode.value === 'local') {
|
||||
await runLocal()
|
||||
} else {
|
||||
await runServer()
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
loading, error, autoApply, execTimeMs,
|
||||
apply: applyDetection, onParameterChange,
|
||||
} = useEditorExecution(executeDetection)
|
||||
|
||||
// --- Init ---
|
||||
|
||||
onMounted(async () => {
|
||||
// Try loading from API, fall back to hardcoded defaults
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/config/stages/${props.stage}`)
|
||||
if (resp.ok) {
|
||||
@@ -85,26 +85,25 @@ onMounted(async () => {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
|
||||
// Auto-run on first frame if already available
|
||||
if (props.frameImage) {
|
||||
applyDetection()
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-run when frame arrives after mount (checkpoint load is async)
|
||||
watch(() => props.frameImage, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal && fields.value.length > 0) {
|
||||
applyDetection()
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-run when selection range changes (strip handle drag)
|
||||
watch([() => props.selectionStart, () => props.selectionEnd], () => {
|
||||
if (autoApply.value) onSliderChange()
|
||||
if (autoApply.value) onParameterChange()
|
||||
})
|
||||
|
||||
const numericFields = computed(() => fields.value.filter(f => f.type === 'int' || f.type === 'float'))
|
||||
const boolFields = computed(() => fields.value.filter(f => f.type === 'bool'))
|
||||
function onFieldUpdate(name: string, value: unknown) {
|
||||
values.value[name] = value
|
||||
onParameterChange()
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
for (const f of fields.value) {
|
||||
@@ -113,42 +112,9 @@ function resetDefaults() {
|
||||
applyDetection()
|
||||
}
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// --- Local CV execution ---
|
||||
|
||||
function onSliderChange() {
|
||||
if (!autoApply.value) return
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => applyDetection(), 150)
|
||||
}
|
||||
|
||||
async function applyDetection() {
|
||||
const hasFrames = props.frames && props.frames.length > 0
|
||||
if (!props.frameImage && !hasFrames) {
|
||||
error.value = 'No frame available'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
execTimeMs.value = null
|
||||
|
||||
try {
|
||||
if (execMode.value === 'local') {
|
||||
await runLocal()
|
||||
} else {
|
||||
await runServer()
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = `${execMode.value} failed: ${e}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser-side CV — runs on current frame or selection range */
|
||||
async function runLocal() {
|
||||
const t0 = performance.now()
|
||||
|
||||
const params: Partial<EdgeDetectionParams> = {
|
||||
cannyLow: values.value['edge_canny_low'] as number,
|
||||
cannyHigh: values.value['edge_canny_high'] as number,
|
||||
@@ -159,7 +125,6 @@ async function runLocal() {
|
||||
pairMinDistance: values.value['edge_pair_min_distance'] as number,
|
||||
}
|
||||
|
||||
// Determine which frames to process
|
||||
const targetFrames = props.frames && props.frames.length > 0
|
||||
? props.frames.slice(props.selectionStart ?? 0, (props.selectionEnd ?? props.frames.length - 1) + 1)
|
||||
: [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage! }]
|
||||
@@ -195,19 +160,18 @@ async function runLocal() {
|
||||
totalRegions += result.regions.length
|
||||
regions_by_frame[frameKey] = result.regions
|
||||
}
|
||||
|
||||
// Emit incrementally so overlays appear per frame, not all at once
|
||||
emit('replay-result', { regions_by_frame: { ...regions_by_frame }, debug: { ...debug }, frameWidth, frameHeight })
|
||||
}
|
||||
|
||||
processingIndex.value = null
|
||||
execTimeMs.value = Math.round(performance.now() - t0)
|
||||
regionCount.value = totalRegions
|
||||
|
||||
emit('replay-result', { regions_by_frame, debug, frameWidth, frameHeight })
|
||||
}
|
||||
|
||||
/** Server-side CV — calls GPU box via proxy */
|
||||
async function runServer() {
|
||||
const t0 = performance.now()
|
||||
// --- Server CV execution ---
|
||||
|
||||
async function runServer() {
|
||||
const body: Record<string, unknown> = { image: props.frameImage }
|
||||
for (const f of fields.value) {
|
||||
if (f.name !== 'enabled') {
|
||||
@@ -231,7 +195,6 @@ async function runServer() {
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
execTimeMs.value = Math.round(performance.now() - t0)
|
||||
regionCount.value = data.regions?.length ?? 0
|
||||
|
||||
const frameKey = String(props.frameRef ?? 0)
|
||||
@@ -260,59 +223,24 @@ async function runServer() {
|
||||
<button class="sliders-reset" @click="resetDefaults" title="Reset to defaults">Reset</button>
|
||||
</div>
|
||||
|
||||
<!-- Local / Server toggle -->
|
||||
<div class="mode-toggle">
|
||||
<button
|
||||
:class="['mode-btn', { active: execMode === 'local' }]"
|
||||
@click="execMode = 'local'"
|
||||
>Local</button>
|
||||
<button
|
||||
:class="['mode-btn', { active: execMode === 'server' }]"
|
||||
@click="execMode = 'server'"
|
||||
>Server</button>
|
||||
<button :class="['mode-btn', { active: execMode === 'local' }]" @click="execMode = 'local'">Local</button>
|
||||
<button :class="['mode-btn', { active: execMode === 'server' }]" @click="execMode = 'server'">Server</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="sliders-error">{{ error }}</div>
|
||||
|
||||
<div class="sliders-list">
|
||||
<!-- Boolean fields -->
|
||||
<label v-for="f in boolFields" :key="f.name" class="slider-field bool-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="!!values[f.name]"
|
||||
@change="(e) => { values[f.name] = (e.target as HTMLInputElement).checked; onSliderChange() }"
|
||||
/>
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/_/g, ' ') }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Numeric fields (range sliders) -->
|
||||
<div v-for="f in numericFields" :key="f.name" class="slider-field">
|
||||
<div class="field-header">
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/^edge_/, '').replace(/_/g, ' ') }}</span>
|
||||
<span class="field-value">{{ values[f.name] }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:min="f.min ?? 0"
|
||||
:max="f.max ?? 500"
|
||||
:step="f.type === 'float' ? 0.01 : 1"
|
||||
:value="values[f.name] as number"
|
||||
@input="(e) => { values[f.name] = Number((e.target as HTMLInputElement).value); onSliderChange() }"
|
||||
/>
|
||||
<div class="field-range">
|
||||
<span>{{ f.min ?? 0 }}</span>
|
||||
<span>{{ f.max ?? 500 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ParameterEditor
|
||||
:fields="fields"
|
||||
:values="values"
|
||||
@update="onFieldUpdate"
|
||||
@reset="resetDefaults"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sliders-footer">
|
||||
<button
|
||||
class="apply-btn"
|
||||
:disabled="loading || autoApply"
|
||||
@click="applyDetection"
|
||||
>
|
||||
<button class="apply-btn" :disabled="loading || autoApply" @click="applyDetection">
|
||||
{{ loading ? 'Running...' : 'Apply' }}
|
||||
</button>
|
||||
<label class="auto-apply-toggle">
|
||||
@@ -411,79 +339,6 @@ async function runServer() {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.slider-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bool-field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
color: var(--text-primary);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--surface-3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
}
|
||||
|
||||
.sliders-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -66,11 +66,13 @@ export function useCheckpointLoader(
|
||||
currentFrameRef.value = frame.seq
|
||||
}
|
||||
|
||||
const { checkpointStageFor } = useStageRegistry()
|
||||
const { stages, checkpointStageFor } = useStageRegistry()
|
||||
|
||||
// Auto-load checkpoint when entering editor mode
|
||||
// Auto-load checkpoint when entering editor mode.
|
||||
// Also watches stages — the registry fetch is async, so on first load
|
||||
// stages may be empty. When they arrive, re-evaluate.
|
||||
watch(
|
||||
() => [pipeline.layoutMode, pipeline.editorStage, jobId.value] as const,
|
||||
() => [pipeline.layoutMode, pipeline.editorStage, jobId.value, stages.value.length] as const,
|
||||
([mode, stage, job]) => {
|
||||
if (mode === 'bbox_editor' && stage && job) {
|
||||
const cpStage = checkpointStageFor(stage)
|
||||
|
||||
145
ui/framework/src/components/ParameterEditor.vue
Normal file
145
ui/framework/src/components/ParameterEditor.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface ConfigField {
|
||||
name: string
|
||||
type: string
|
||||
default: unknown
|
||||
description: string
|
||||
min: number | null
|
||||
max: number | null
|
||||
options: string[] | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ConfigField[]
|
||||
values: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update': [name: string, value: unknown]
|
||||
'reset': []
|
||||
}>()
|
||||
|
||||
const numericFields = computed(() => props.fields.filter(f => f.type === 'int' || f.type === 'float'))
|
||||
const boolFields = computed(() => props.fields.filter(f => f.type === 'bool'))
|
||||
|
||||
function onInput(name: string, value: unknown) {
|
||||
emit('update', name, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="param-editor">
|
||||
<!-- Boolean fields -->
|
||||
<label v-for="f in boolFields" :key="f.name" class="param-field bool-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="!!values[f.name]"
|
||||
@change="(e) => onInput(f.name, (e.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/_/g, ' ') }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Numeric fields (range sliders) -->
|
||||
<div v-for="f in numericFields" :key="f.name" class="param-field">
|
||||
<div class="field-header">
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/^edge_/, '').replace(/_/g, ' ') }}</span>
|
||||
<span class="field-value">{{ values[f.name] }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:min="f.min ?? 0"
|
||||
:max="f.max ?? 500"
|
||||
:step="f.type === 'float' ? 0.01 : 1"
|
||||
:value="values[f.name] as number"
|
||||
@input="(e) => onInput(f.name, Number((e.target as HTMLInputElement).value))"
|
||||
/>
|
||||
<div class="field-range">
|
||||
<span>{{ f.min ?? 0 }}</span>
|
||||
<span>{{ f.max ?? 500 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.param-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.param-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bool-field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
color: var(--text-primary);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--surface-3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
}
|
||||
</style>
|
||||
@@ -64,9 +64,11 @@ const isHorizontal = computed(() => props.direction === 'horizontal')
|
||||
|
||||
const sizedStyle = computed(() => {
|
||||
if (props.sizeMode === 'px') {
|
||||
const sizeStr = size.value + 'px'
|
||||
const minStr = props.min + 'px'
|
||||
return isHorizontal.value
|
||||
? { width: size.value + 'px', flexShrink: '0' }
|
||||
: { height: size.value + 'px', flexShrink: '0' }
|
||||
? { width: sizeStr, minWidth: minStr, flexShrink: '0' }
|
||||
: { height: sizeStr, minHeight: minStr, flexShrink: '0' }
|
||||
}
|
||||
return { flex: String(size.value) }
|
||||
})
|
||||
|
||||
57
ui/framework/src/composables/useEditorExecution.ts
Normal file
57
ui/framework/src/composables/useEditorExecution.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface EditorExecutionOptions {
|
||||
/** Debounce delay in ms for auto-apply. Default: 150 */
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic editor execution pattern — debounced apply with auto-apply toggle,
|
||||
* loading/error/timing state tracking.
|
||||
*
|
||||
* The caller provides the actual execution function. This composable handles
|
||||
* the orchestration: debounce, auto-apply, loading state, timing.
|
||||
*/
|
||||
export function useEditorExecution(
|
||||
executeFn: () => Promise<void>,
|
||||
options: EditorExecutionOptions = {},
|
||||
) {
|
||||
const debounceMs = options.debounceMs ?? 150
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const autoApply = ref(true)
|
||||
const execTimeMs = ref<number | null>(null)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function apply() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
execTimeMs.value = null
|
||||
const t0 = performance.now()
|
||||
try {
|
||||
await executeFn()
|
||||
execTimeMs.value = Math.round(performance.now() - t0)
|
||||
} catch (e) {
|
||||
error.value = String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onParameterChange() {
|
||||
if (!autoApply.value) return
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => apply(), debounceMs)
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
autoApply,
|
||||
execTimeMs,
|
||||
apply,
|
||||
onParameterChange,
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,16 @@ export { SSEDataSource } from './datasources/SSEDataSource'
|
||||
export { StaticDataSource } from './datasources/StaticDataSource'
|
||||
export { useDataSource } from './composables/useDataSource'
|
||||
export { useRegistry } from './composables/useRegistry'
|
||||
export { useEditorExecution } from './composables/useEditorExecution'
|
||||
export type { EditorExecutionOptions } from './composables/useEditorExecution'
|
||||
|
||||
// Components
|
||||
export { default as Panel } from './components/Panel.vue'
|
||||
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
||||
export { default as ResizeHandle } from './components/ResizeHandle.vue'
|
||||
export { default as SplitPane } from './components/SplitPane.vue'
|
||||
export { default as ParameterEditor } from './components/ParameterEditor.vue'
|
||||
export type { ConfigField } from './components/ParameterEditor.vue'
|
||||
|
||||
// Renderers
|
||||
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||
|
||||
Reference in New Issue
Block a user