Six fixes that were ours regardless of what the sister site does. Four came out of our own reviews; two you reported from the settings pages. MAX_CONTENT_LENGTH was set nowhere. Every upload route caps its own payload, but each of those checks runs after the body has arrived: the first touch of request.form or request.files makes Werkzeug parse the whole multipart and spool the file to a temp file, so a route can reject the content and still have paid for the transfer and the disk. Three separate reviews found that shape in three different routes, which makes it a property of the app rather than of any handler. 128 MB, clearing the 64 MB agent binary with room for the envelope. The naming gate excluded devnotes with --exclude-dir, which matches a directory of that NAME at any depth - so a force-added plugins/<plugin>/devnotes/ was invisible to every check in the file, ADR-015 included. Replaced with a path anchored post-filter. Verified by planting the same site literal in root devnotes/ AND in plugins/endpoints/devnotes/: the gate now reports exactly one, the nested one. Before, it reported none. BaseModel.to_dict walked __table__.columns and published every one, so a model without an override published whatever it carried - including columns added later by someone not thinking about this method. User has no override, so User.to_dict() returned its bcrypt hash. Nothing calls it in a response path today, which is luck rather than design. A blanket allowlist would empty the output of all 34 models relying on the default, so this pins a floor instead: named secret-bearing columns are never emitted, and a model may extend the set. Eight tests, including a sweep over every mapped model. ActionPicker's Esc handlers had .prevent but not .stop, so the event bubbled to the wrapper and closed the whole picker instead of returning to the monitor list. The branding page reported "error loading image" on the site and badge logos. Two bugs stacked. The load failure was real: the preview used the stored value as a raw src, and that value is root-relative, so under /shopdb or /ops it resolved against the SERVER root and 404d - siteSettings.js wraps every logo in withBase() and this page was the one place that did not. With that fixed two of three were still invisible, because the shipped GE mark is fill="white" for the dark sidebar and was rendering white-on-white. The preview tile now sits on a mid-tone checker so artwork of any colour reads against it. The floor-map levels table ran off the right with its cells crushed - nine columns of inputs, no scroll wrapper, and .data-table is styled nowhere. My first attempt reached for the global .table-container and made it WORSE: that class also sets white-space: nowrap, and a full sentence of hint text lives in one of the cells, so nowrap forced the table wider still. It now scrolls in a local wrapper, the hint wraps, and the name input has a width instead of collapsing to one character. Suite 2377, frontend 324, gate green. Scope: product
266 lines
8.3 KiB
Vue
266 lines
8.3 KiB
Vue
<template>
|
|
<div class="action-picker" @keydown.esc="closeDropdown">
|
|
<input
|
|
ref="inputEl"
|
|
type="text"
|
|
class="form-control"
|
|
:placeholder="placeholder"
|
|
v-model="query"
|
|
@focus="openDropdown"
|
|
@input="openDropdown"
|
|
/>
|
|
|
|
<div v-if="open" class="action-dropdown">
|
|
<!-- Command level: commands + a Monitor branch entry -->
|
|
<template v-if="level === 'command'">
|
|
<div
|
|
v-for="opt in filteredCommands"
|
|
:key="opt.action"
|
|
class="action-option"
|
|
@mousedown.prevent="selectCommand(opt)"
|
|
>
|
|
{{ opt.label || opt.action }}
|
|
</div>
|
|
<div
|
|
class="action-option action-branch"
|
|
@mousedown.prevent="enterMonitorLevel"
|
|
>
|
|
Monitor...
|
|
</div>
|
|
<div v-if="filteredCommands.length === 0" class="action-empty">No matching actions</div>
|
|
</template>
|
|
|
|
<!-- Monitor level: two create entries pinned on top, then existing monitors -->
|
|
<template v-else-if="level === 'monitor'">
|
|
<div class="action-option action-create" @mousedown.prevent="startCreate('process')">
|
|
<Create Process Monitor>
|
|
</div>
|
|
<div class="action-option action-create" @mousedown.prevent="startCreate('service')">
|
|
<Create Service Monitor>
|
|
</div>
|
|
<div class="action-divider"></div>
|
|
<div
|
|
v-for="m in filteredMonitors"
|
|
:key="m.endpointmonitorid"
|
|
class="action-option"
|
|
@mousedown.prevent="selectMonitor(m)"
|
|
>
|
|
{{ m.displayname }} <span class="monitor-kind">({{ m.kind }})</span>
|
|
</div>
|
|
<div v-if="filteredMonitors.length === 0" class="action-empty">No matching monitors</div>
|
|
</template>
|
|
|
|
<!-- Create level: inline text field to name the new process/service monitor -->
|
|
<template v-else>
|
|
<div class="action-create-form" @mousedown.prevent>
|
|
<label class="action-create-label">
|
|
{{ createkind === 'service' ? 'Service name to monitor' : 'Process name to monitor' }}
|
|
</label>
|
|
<input
|
|
ref="createInputEl"
|
|
type="text"
|
|
class="form-control"
|
|
:placeholder="createkind === 'service' ? 'e.g. Spooler' : 'e.g. agent.exe'"
|
|
v-model="createname"
|
|
@keydown.enter.prevent="submitCreate"
|
|
@keydown.esc.prevent.stop="cancelCreate"
|
|
/>
|
|
<template v-if="createkind === 'process'">
|
|
<label class="action-create-label">
|
|
Launch path (optional)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="e.g. C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
|
|
v-model="createlaunchpath"
|
|
@keydown.enter.prevent="submitCreate"
|
|
@keydown.esc.prevent.stop="cancelCreate"
|
|
/>
|
|
</template>
|
|
<div class="action-create-actions">
|
|
<button type="button" class="btn btn-primary btn-sm" :disabled="!createname.trim() || creating" @mousedown.prevent="submitCreate">
|
|
Create
|
|
</button>
|
|
<button type="button" class="btn btn-secondary btn-sm" @mousedown.prevent="cancelCreate">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, nextTick } from 'vue'
|
|
import { endpointsApi } from '@/api'
|
|
import { apiError } from '@/utils/apiError'
|
|
import { useToast } from '@/composables/toast'
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: String, default: '' },
|
|
commandActions: { type: Array, default: () => [] },
|
|
monitors: { type: Array, default: () => [] }
|
|
})
|
|
const emit = defineEmits(['update:modelValue', 'monitor-created'])
|
|
|
|
const toast = useToast()
|
|
|
|
const inputEl = ref(null)
|
|
const createInputEl = ref(null)
|
|
const open = ref(false)
|
|
// 'command' -> top level, 'monitor' -> monitor list, 'create' -> inline name form
|
|
const level = ref('command')
|
|
const query = ref('')
|
|
const createkind = ref('process')
|
|
const createname = ref('')
|
|
const createlaunchpath = ref('')
|
|
const creating = ref(false)
|
|
|
|
// Show the chosen action's friendly label in the input when not actively typing.
|
|
watch(() => props.modelValue, syncLabel, { immediate: true })
|
|
watch(() => props.monitors, () => { if (!open.value) syncLabel() })
|
|
|
|
function syncLabel() {
|
|
query.value = labelFor(props.modelValue)
|
|
}
|
|
|
|
function labelFor(action) {
|
|
if (!action) return ''
|
|
if (action.startsWith('monitor:')) {
|
|
const id = Number(action.slice('monitor:'.length))
|
|
const monitor = props.monitors.find(m => m.endpointmonitorid === id)
|
|
return monitor ? `Monitor: ${monitor.displayname} (${monitor.kind})` : action
|
|
}
|
|
const command = props.commandActions.find(c => c.action === action)
|
|
return command ? (command.label || command.action) : action
|
|
}
|
|
|
|
const placeholder = computed(() =>
|
|
level.value === 'monitor' ? 'Search monitors...' : 'Search actions...')
|
|
|
|
const filteredCommands = computed(() => {
|
|
const term = query.value.trim().toLowerCase()
|
|
if (!term) return props.commandActions
|
|
return props.commandActions.filter(c =>
|
|
(c.label || c.action).toLowerCase().includes(term))
|
|
})
|
|
|
|
const filteredMonitors = computed(() => {
|
|
const term = query.value.trim().toLowerCase()
|
|
if (!term) return props.monitors
|
|
return props.monitors.filter(m => m.displayname.toLowerCase().includes(term))
|
|
})
|
|
|
|
function openDropdown() {
|
|
open.value = true
|
|
}
|
|
|
|
function closeDropdown() {
|
|
open.value = false
|
|
level.value = 'command'
|
|
resetCreate()
|
|
syncLabel()
|
|
}
|
|
|
|
function enterMonitorLevel() {
|
|
level.value = 'monitor'
|
|
query.value = ''
|
|
inputEl.value?.focus()
|
|
}
|
|
|
|
function selectCommand(opt) {
|
|
emit('update:modelValue', opt.action)
|
|
open.value = false
|
|
level.value = 'command'
|
|
query.value = opt.label || opt.action
|
|
}
|
|
|
|
function selectMonitor(monitor) {
|
|
emit('update:modelValue', `monitor:${monitor.endpointmonitorid}`)
|
|
open.value = false
|
|
level.value = 'command'
|
|
query.value = `Monitor: ${monitor.displayname} (${monitor.kind})`
|
|
}
|
|
|
|
async function submitCreate() {
|
|
const displayname = createname.value.trim()
|
|
if (!displayname || creating.value) return
|
|
const kind = createkind.value
|
|
const payload = {
|
|
kind,
|
|
displayname,
|
|
processname: kind === 'process' ? displayname : '',
|
|
servicename: kind === 'service' ? displayname : '',
|
|
isactive: true
|
|
}
|
|
if (kind === 'process' && createlaunchpath.value.trim()) {
|
|
payload.launchpath = createlaunchpath.value.trim()
|
|
}
|
|
creating.value = true
|
|
try {
|
|
const response = await endpointsApi.monitors.create(payload)
|
|
const created = response.data.data
|
|
emit('monitor-created', created)
|
|
emit('update:modelValue', `monitor:${created.endpointmonitorid}`)
|
|
open.value = false
|
|
level.value = 'command'
|
|
resetCreate()
|
|
query.value = `Monitor: ${created.displayname} (${created.kind})`
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to create monitor'))
|
|
} finally {
|
|
creating.value = false
|
|
}
|
|
}
|
|
|
|
function startCreate(kind) {
|
|
createkind.value = kind
|
|
createname.value = ''
|
|
createlaunchpath.value = ''
|
|
level.value = 'create'
|
|
nextTick(() => createInputEl.value?.focus())
|
|
}
|
|
|
|
function resetCreate() {
|
|
createname.value = ''
|
|
createlaunchpath.value = ''
|
|
creating.value = false
|
|
}
|
|
|
|
function cancelCreate() {
|
|
resetCreate()
|
|
level.value = 'monitor'
|
|
nextTick(() => inputEl.value?.focus())
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.action-picker { position: relative; flex: 1; min-width: 220px; }
|
|
.action-dropdown {
|
|
position: absolute;
|
|
z-index: 20;
|
|
left: 0;
|
|
right: 0;
|
|
margin-top: 2px;
|
|
max-height: 320px;
|
|
overflow-y: auto;
|
|
background: var(--bg-card-solid);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
|
}
|
|
.action-option { padding: 8px 12px; cursor: pointer; color: var(--text); }
|
|
.action-option:hover { background: var(--bg); }
|
|
.action-branch { font-style: italic; }
|
|
.action-create { font-weight: 600; color: var(--primary); }
|
|
.action-divider { height: 1px; background: var(--border); margin: 2px 0; }
|
|
.action-empty { padding: 6px 10px; color: var(--text-light); }
|
|
.monitor-kind { color: var(--text-light); }
|
|
.action-create-form { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
|
.action-create-label { font-size: 12px; color: var(--text-light); }
|
|
.action-create-actions { display: flex; gap: 8px; }
|
|
</style>
|