Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@
['name' => 'database_profiler#explain', 'url' => '/explain/{token}/{query}', 'verb' => 'GET'],
['name' => 'main#profiler', 'url' => '/profiler/{profiler}/{token}/', 'verb' => 'GET'],
['name' => 'main#profileInfo', 'url' => '/profile/{token}/', 'verb' => 'GET'],
['name' => 'main#profiles', 'url' => '/profiles', 'verb' => 'GET'],
],
];
12 changes: 12 additions & 0 deletions lib/Controller/MainController.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,16 @@ public function profileInfo(string $token): DataResponse {
'profile' => $this->profiler->loadProfile($token),
]);
}

/**
* Search through the stored profiles.
*/
#[NoCSRFRequired]
public function profiles(?string $url = null, ?string $method = null, ?string $statusCode = null, int $limit = 50): DataResponse {
$profiles = $this->profiler->find($url, $limit, $method, null, null, $statusCode);

return new DataResponse([
'profiles' => $profiles,
]);
}
}
6 changes: 6 additions & 0 deletions src/router/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import RequestView from '../views/RequestView.vue'
import LdapView from '../views/LdapView.vue'
import CacheView from '../views/CacheView.vue'
import EventsView from '../views/EventsView.vue'
import ProfilesView from '../views/ProfilesView.vue'
import { getRootUrl, generateUrl } from '@nextcloud/router'

const webRootWithIndexPHP = getRootUrl() + '/index.php'
Expand Down Expand Up @@ -54,6 +55,11 @@ const routes = [
component: CacheView,
props: true,
},
{
path: '/apps/profiler/profiles/',
name: 'profiles',
component: ProfilesView,
},
]

export default createRouter({
Expand Down
8 changes: 8 additions & 0 deletions src/views/Profiler.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<NcContent app-name="profiler">
<NcAppNavigation>
<template #list>
<NcAppNavigationItem :to="{ name: 'profiles' }"
name="All profiles">
<template #icon>
<FormatListBulleted :size="20" />
</template>
</NcAppNavigationItem>

<NcAppNavigationCaption name="Categories" />
<NcAppNavigationItem v-for="cat in categoryInfo"
:key="cat.id"
Expand Down Expand Up @@ -55,6 +62,7 @@ import ChartGantt from 'vue-material-design-icons/ChartGantt.vue'
import Cached from 'vue-material-design-icons/Cached.vue'
import Account from 'vue-material-design-icons/Account.vue'
import ServerNetwork from 'vue-material-design-icons/ServerNetwork.vue'
import FormatListBulleted from 'vue-material-design-icons/FormatListBulleted.vue'

import { watch, ref, onMounted } from 'vue'
import { useStore } from '../store'
Expand Down
2 changes: 1 addition & 1 deletion src/views/ProfilerToolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ function generateAjaxUrl(stackElement: StackElement): string {
function openProfiler(view: string): void {
document.location = generateUrl('/apps/profiler/profiler/{view}/{token}', {
view,
token: this.token,
token,
})
}
</script>
Expand Down
252 changes: 252 additions & 0 deletions src/views/ProfilesView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
<!--
SPDX-FileCopyrightText: 2026 Carl Schwan <carl@carlschwan.eu>

SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<div class="profiles-view">
<h2>All profiles</h2>
<div class="filters">
<NcTextField v-model="search.url"
class="filters__url"
label="Search by URL"
placeholder="/apps/files/..."
trailing-button-icon="close"
:show-trailing-button="!!search.url"
@trailing-button-click="search.url = ''">
<template #icon>
<Magnify :size="16" />
</template>
</NcTextField>
<NcSelect v-model="search.method"
class="filters__method"
:options="methodOptions"
:clearable="true"
placeholder="Method" />
<NcTextField v-model="search.statusCode"
class="filters__status"
label="Status code"
placeholder="200"
trailing-button-icon="close"
:show-trailing-button="!!search.statusCode"
@trailing-button-click="search.statusCode = ''" />
</div>

<NcEmptyContent v-if="!loading && profiles.length === 0"
name="No profiles found"
description="Try changing your search filters.">
<template #icon>
<Magnify />
</template>
</NcEmptyContent>

<div v-else style="overflow-x:auto;">
<table>
<thead>
<tr>
<th class="nowrap">
Method
</th>
<th style="width: 100%;">
URL
</th>
<th class="nowrap">
Status
</th>
<th class="nowrap">
Time
</th>
</tr>
</thead>
<tbody>
<tr v-for="profile in profiles"
:key="profile.token"
role="button"
class="profile-row"
@click="openProfile(profile)">
<td class="nowrap">
{{ profile.method }}
</td>
<td class="url-cell">
{{ profile.url }}
</td>
<td class="nowrap" :class="statusClass(profile.status_code)">
{{ profile.status_code }}
</td>
<td class="nowrap">
{{ formatTime(profile.time) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

<script lang="ts" setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import Magnify from 'vue-material-design-icons/Magnify.vue'
import { useStore } from '../store'

interface FoundProfile {
token: string,
method: string,
url: string,
time: number,
parent: string|null,
status_code: string,
}

const router = useRouter()
const store = useStore()

const methodOptions = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']

const search = reactive({
url: '',
method: null,
statusCode: '',
})

const profiles = ref<FoundProfile[]>([])
const loading = ref(false)
let debounceTimeout: ReturnType<typeof setTimeout>|null = null

async function fetchProfiles(): Promise<void> {
loading.value = true
try {
const response = await axios.get(generateUrl('/apps/profiler/profiles'), {
params: {
url: search.url || undefined,
method: search.method || undefined,
statusCode: search.statusCode || undefined,
},
})
profiles.value = response.data.profiles
} finally {
loading.value = false
}
}

function scheduleFetch(): void {
if (debounceTimeout) {
clearTimeout(debounceTimeout)
}
debounceTimeout = setTimeout(fetchProfiles, 300)
}

watch(() => [search.url, search.method, search.statusCode], scheduleFetch)

onMounted(fetchProfiles)

function openProfile(profile: FoundProfile): void {
store.loadProfile({ token: profile.token })
router.push({ name: 'db', params: { token: profile.token } })
}

function statusClass(statusCode: string): string {
const code = parseInt(statusCode, 10)
if (code >= 500) {
return 'status-error'
}
if (code >= 400) {
return 'status-warning'
}
return 'status-success'
}

function formatTime(time: number): string {
return new Date(time * 1000).toLocaleString()
}
</script>

<style scoped lang="scss">
.profiles-view {
max-width: 100%;
}

.filters {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 1rem;
margin-block-end: 1rem;

&__url {
flex: 1 1 auto;
}

&__method {
width: 160px;
}

&__status {
width: 120px;
}
}

table {
background: var(--color-background-darker);
border: var(--border-color-dark);
box-shadow: rgba(32, 32, 32, 0.2) 0 0 1px 0;
margin: 1em 0;
width: 100%;
}

table, tr, th, td {
background: var(--table-background);
border-collapse: collapse;
line-height: 1.5;
vertical-align: top !important;
}

thead tr {
background: var(--color-background-dark);
}

table th, table td {
padding: 8px 10px;
}

table tbody th, table tbody td {
border: 1px solid #ddd;
border-width: 1px 0;
font-family: monospace;
font-size: 13px;
}

.nowrap {
white-space: nowrap;
}

.url-cell {
word-break: break-all;
}

.profile-row {
cursor: pointer;
}

tbody tr:hover {
background-color: var(--color-background-hover);
}

.status-success {
color: var(--color-success);
}

.status-warning {
color: var(--color-warning);
}

.status-error {
color: var(--color-error);
}
</style>
Loading