├── .dockerignore ├── include ├── sites │ ├── config │ │ ├── ssl │ │ │ └── .gitkeep │ │ └── vhosts │ │ │ ├── localhost.conf │ │ │ ├── test.com.conf │ │ │ ├── localhost-ssl.conf │ │ │ └── test.com-ssl.conf │ ├── test │ │ ├── logs │ │ │ └── .gitkeep │ │ ├── ssl │ │ │ └── .gitkeep │ │ └── html │ │ │ └── public │ │ │ └── index.php │ └── localhost │ │ ├── logs │ │ └── .gitkeep │ │ ├── ssl │ │ └── .gitkeep │ │ └── html │ │ └── public │ │ └── index.php ├── php-spx │ ├── spx.ini │ └── assets │ │ └── web-ui │ │ ├── js │ │ ├── svg.js │ │ ├── fmt.js │ │ ├── dataTable.js │ │ ├── layoutSplitter.js │ │ ├── math.js │ │ ├── utils.js │ │ ├── profileData.js │ │ └── widget.js │ │ ├── css │ │ └── main.css │ │ ├── report.html │ │ └── index.html ├── composer.sh ├── selfsign.sh ├── xdebug.ini └── start.sh ├── apache-php-fpm-alpine.code-workspace ├── apache-php-fpm-alpine.sublime-project ├── .gitignore ├── php-spx ├── build.sh └── Dockerfile ├── LICENSE ├── .vscode └── launch.json ├── docker-compose.yml ├── README.md ├── Dockerfile └── Dockerfile-from-src /.dockerignore: -------------------------------------------------------------------------------- 1 | *.log 2 | -------------------------------------------------------------------------------- /include/sites/config/ssl/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /include/sites/test/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /include/sites/test/ssl/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /include/sites/localhost/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /include/sites/localhost/ssl/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apache-php-fpm-alpine.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ] 7 | } -------------------------------------------------------------------------------- /apache-php-fpm-alpine.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "path": "." 6 | } 7 | ], 8 | "settings": 9 | { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /include/sites/test/html/public/index.php: -------------------------------------------------------------------------------- 1 | &2 echo 'ERROR: Invalid installer checksum' 10 | rm composer-setup.php 11 | exit 1 12 | fi 13 | 14 | php composer-setup.php --quiet 15 | RESULT=$? 16 | rm composer-setup.php 17 | exit $RESULT 18 | -------------------------------------------------------------------------------- /include/selfsign.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | EXPECTED_CHECKSUM="$(php -r 'copy("https://github.com/8ctopus/self-sign/releases/download/0.1.8/selfsign.sha256", "php://stdout");')" 4 | #echo $EXPECTED_CHECKSUM 5 | php -r "copy('https://github.com/8ctopus/self-sign/releases/download/0.1.8/selfsign.phar', '/selfsign.phar');" 6 | ACTUAL_CHECKSUM="$(php -r "echo hash_file('sha256', '/selfsign.phar');")" 7 | 8 | if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ] 9 | then 10 | echo 'ERROR: Invalid checksum' 11 | rm /selfsign.phar 12 | exit 1 13 | else 14 | exit 0 15 | fi 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 8ctopus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /include/xdebug.ini: -------------------------------------------------------------------------------- 1 | zend_extension=xdebug.so 2 | 3 | ############################################## 4 | # common settings 5 | ############################################## 6 | 7 | # values: off,develop,coverage,debug,profile,trace,gcstats 8 | # reference: https://xdebug.org/docs/all_settings#mode 9 | xdebug.mode = develop,debug,coverage 10 | 11 | # enable on all requests vs. trigger 12 | # values: yes,no,trigger,default 13 | xdebug.start_with_request = yes 14 | 15 | # only if trigger mode 16 | xdebug.trigger_value = "" 17 | 18 | # log 19 | xdebug.log = /sites/localhost/logs/xdebug.log 20 | xdebug.log_level = 7 21 | 22 | ############################################## 23 | # debugger 24 | ############################################## 25 | 26 | xdebug.discover_client_host = false 27 | xdebug.client_host = host.docker.internal 28 | xdebug.client_port = 9001 29 | xdebug.connect_timeout_ms = 200 30 | 31 | ############################################## 32 | # profiler 33 | ############################################## 34 | 35 | xdebug.output_dir = "/sites/localhost/logs/" 36 | xdebug.profiler_output_name = "cachegrind.out.%s.%H" 37 | 38 | # misc. 39 | xdebug.profiler_append = 0 40 | xdebug.idekey=PHPSTORM 41 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "php docker (9001)", 6 | "type": "php", 7 | "request": "launch", 8 | "port": 9001, 9 | "log": false, 10 | "stopOnEntry": true, 11 | "pathMappings": { 12 | "/sites/": "${workspaceRoot}/sites/" 13 | } 14 | }, 15 | { 16 | "name": "php listen CLI", 17 | "type": "php", 18 | "port": 9000, 19 | "stopOnEntry": true, 20 | "request": "launch" 21 | }, 22 | { 23 | "name": "php run CLI", 24 | "type": "php", 25 | "request": "launch", 26 | "program": "${file}", 27 | "args": [ 28 | "speed", 29 | "--iterations", 30 | "2", 31 | "--verbose", 32 | ], 33 | "cwd": "${fileDirname}", 34 | "port": 9000, 35 | "stopOnEntry": true 36 | }, 37 | { 38 | "name": "js attach", 39 | "type": "chrome", 40 | "request": "attach", 41 | "url": "https://localhost", 42 | "port": 9222, 43 | "webRoot": "${workspaceFolder}/html" 44 | }, 45 | { 46 | "name": "js launch", 47 | "type": "chrome", 48 | "request": "launch", 49 | "url": "https://localhost", 50 | "port": 9222, 51 | "webRoot": "${workspaceFolder}/html" 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /php-spx/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VERSION=latest 2 | FROM alpine:${VERSION} AS build 3 | 4 | # update repositories to edge 5 | RUN printf "https://dl-cdn.alpinelinux.org/alpine/edge/main\nhttps://dl-cdn.alpinelinux.org/alpine/edge/community\n" > /etc/apk/repositories 6 | 7 | # add testing repository 8 | RUN printf "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing\n" >> /etc/apk/repositories 9 | 10 | # update apk repositories 11 | RUN apk update 12 | 13 | # upgrade all 14 | RUN apk upgrade 15 | 16 | # add c build tools 17 | RUN apk add build-base 18 | 19 | # add dev libraries 20 | RUN apk add \ 21 | php85-dev@testing \ 22 | zlib-dev@testing 23 | 24 | # fix ./configure "Cannot find php-config. Please use --with-php-config=PATH" 25 | RUN ln -s /usr/bin/php-config85 /usr/bin/php-config 26 | 27 | # add git 28 | RUN apk add git 29 | 30 | # clone php-spx 31 | RUN git clone --depth 50 https://github.com/NoiseByNorthwest/php-spx.git 32 | 33 | # set workdir 34 | WORKDIR /php-spx 35 | 36 | # checkout release - we need master for now 37 | #RUN git checkout tags/v0.4.22 38 | 39 | RUN sed -i 's|CFLAGS="\$CFLAGS -Werror -Wall -O3 -pthread -std=gnu90"|CFLAGS="$CFLAGS -Wall -O3 -pthread -std=gnu90"|' /php-spx/config.m4 40 | 41 | # build php-spx 42 | RUN phpize85 43 | RUN ./configure 44 | RUN make 45 | 46 | # start again with a new image 47 | FROM scratch 48 | 49 | # get version 50 | ARG VERSION 51 | 52 | # copy spx module from alpine image to the scratch image so files can be copied back to host 53 | COPY --from=build /php-spx/modules/spx.so spx.so 54 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/js/svg.js: -------------------------------------------------------------------------------- 1 | /* SPX - A simple profiler for PHP 2 | * Copyright (C) 2017-2020 Sylvain Lassaut 3 | * 4 | * This program is free software: you can redistribute it and/or modify 5 | * it under the terms of the GNU General Public License as published by 6 | * the Free Software Foundation; either version 3 of the License, or 7 | * (at your option) any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | 19 | 20 | export function createNode(name, attributes, builder) { 21 | let node = document.createElementNS("http://www.w3.org/2000/svg", name); 22 | for (let k in attributes || {}) { 23 | node.setAttribute(k, attributes[k]); 24 | } 25 | 26 | if (builder) { 27 | builder(node); 28 | } 29 | 30 | return node; 31 | } 32 | 33 | export class NodePool { 34 | 35 | constructor(name) { 36 | this.name = name; 37 | this.nodes = []; 38 | this.top = 0; 39 | } 40 | 41 | acquire(attributes, builder) { 42 | if (this.nodes.length == this.top) { 43 | this.nodes.push(createNode(this.name)); 44 | } 45 | 46 | const node = this.nodes[this.top]; 47 | this.top++; 48 | 49 | for (let k in attributes || {}) { 50 | node.setAttribute(k, attributes[k]); 51 | } 52 | 53 | if (builder) { 54 | builder(node); 55 | } 56 | 57 | return node; 58 | } 59 | 60 | releaseAll() { 61 | this.top = 0; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /include/sites/config/vhosts/localhost.conf: -------------------------------------------------------------------------------- 1 | 2 | # server domain name 3 | ServerName localhost 4 | 5 | # site code directory 6 | DocumentRoot /sites/localhost/html/public 7 | 8 | # accept php and html files as directory index 9 | DirectoryIndex index.php index.html 10 | 11 | # access and error logs 12 | ErrorLog /sites/localhost/logs/error_log 13 | CustomLog /sites/localhost/logs/access_log combined 14 | 15 | # custom error log format 16 | ErrorLogFormat "[%{cz}t] [%l] [client %a] %M, referer: %{Referer}i" 17 | 18 | # log 404 as errors 19 | LogLevel core:info 20 | 21 | # set which file apache will serve when url is a directory 22 | DirectoryIndex index.html index.php 23 | 24 | # fix http basic authentication 25 | SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 26 | 27 | # configure site code directory 28 | 29 | # Normally, if multiple Options could apply to a directory, then the most specific one is used and others are ignored; the options are not merged. (See how sections are merged.) 30 | # However if all the options on the Options directive are preceded by a + or - symbol, the options are merged. 31 | # Any options preceded by a + are added to the options currently in force, and any options preceded by a - are removed from the options currently in force. 32 | Options -ExecCGI +FollowSymLinks -SymLinksIfOwnerMatch -Includes -IncludesNOEXEC -Indexes -MultiViews 33 | 34 | # define what Options directives can be overriden in .htaccess 35 | AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch 36 | 37 | # set who can access the directory 38 | Require all granted 39 | 40 | 41 | # file php extension handled by php-fpm 42 | 43 | SetHandler "proxy:unix:/var/run/php-fpm8.sock|fcgi://localhost" 44 | 45 | 46 | -------------------------------------------------------------------------------- /include/sites/config/vhosts/test.com.conf: -------------------------------------------------------------------------------- 1 | 2 | # server domain name 3 | ServerName test.com 4 | 5 | # other domain names server responds to 6 | ServerAlias www.test.com 7 | 8 | # site code directory 9 | DocumentRoot /sites/test/html/public 10 | 11 | # accept php and html files as directory index 12 | DirectoryIndex index.php index.html 13 | 14 | # access and error logs 15 | ErrorLog /sites/test/logs/error_log 16 | CustomLog /sites/test/logs/access_log combined 17 | 18 | # custom error log format 19 | ErrorLogFormat "[%{cz}t] [%l] [client %a] %M, referer: %{Referer}i" 20 | 21 | # log 404 as errors 22 | LogLevel core:info 23 | 24 | # set which file apache will serve when url is a directory 25 | DirectoryIndex index.html index.php 26 | 27 | # fix http basic authentication 28 | SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 29 | 30 | # configure site code directory 31 | 32 | # Normally, if multiple Options could apply to a directory, then the most specific one is used and others are ignored; the options are not merged. (See how sections are merged.) 33 | # However if all the options on the Options directive are preceded by a + or - symbol, the options are merged. 34 | # Any options preceded by a + are added to the options currently in force, and any options preceded by a - are removed from the options currently in force. 35 | Options -ExecCGI +FollowSymLinks -SymLinksIfOwnerMatch -Includes -IncludesNOEXEC -Indexes -MultiViews 36 | 37 | # define what Options directives can be overriden in .htaccess 38 | AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch 39 | 40 | # set who can access the directory 41 | Require all granted 42 | 43 | 44 | # file php extension handled by php-fpm 45 | 46 | SetHandler "proxy:unix:/var/run/php-fpm8.sock|fcgi://localhost" 47 | 48 | 49 | -------------------------------------------------------------------------------- /include/sites/config/vhosts/localhost-ssl.conf: -------------------------------------------------------------------------------- 1 | 2 | # server domain name 3 | ServerName localhost 4 | 5 | # site code directory 6 | DocumentRoot /sites/localhost/html/public 7 | 8 | # accept php and html files as directory index 9 | DirectoryIndex index.php index.html 10 | 11 | # access and error logs 12 | ErrorLog /sites/localhost/logs/error_log 13 | CustomLog /sites/localhost/logs/access_log combined 14 | 15 | # custom error log format 16 | ErrorLogFormat "[%{cz}t] [%l] [client %a] %M, referer: %{Referer}i" 17 | 18 | # log 404 as errors 19 | LogLevel core:info 20 | 21 | # set which file apache will serve when url is a directory 22 | DirectoryIndex index.html index.php 23 | 24 | # fix http basic authentication 25 | SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 26 | 27 | # configure site code directory 28 | 29 | # Normally, if multiple Options could apply to a directory, then the most specific one is used and others are ignored; the options are not merged. (See how sections are merged.) 30 | # However if all the options on the Options directive are preceded by a + or - symbol, the options are merged. 31 | # Any options preceded by a + are added to the options currently in force, and any options preceded by a - are removed from the options currently in force. 32 | Options -ExecCGI +FollowSymLinks -SymLinksIfOwnerMatch -Includes -IncludesNOEXEC -Indexes -MultiViews 33 | 34 | # define what Options directives can be overriden in .htaccess 35 | AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch 36 | 37 | # set who can access the directory 38 | Require all granted 39 | 40 | 41 | # file php extension handled by php-fpm 42 | 43 | SetHandler "proxy:unix:/var/run/php-fpm8.sock|fcgi://localhost" 44 | 45 | 46 | # use SSL 47 | SSLEngine On 48 | 49 | # certificates 50 | SSLCertificateFile /sites/localhost/ssl/certificate.pem 51 | SSLCertificateKeyFile /sites/localhost/ssl/private.key 52 | SSLCACertificateFile /sites/config/ssl/certificate_authority.pem 53 | 54 | -------------------------------------------------------------------------------- /include/sites/config/vhosts/test.com-ssl.conf: -------------------------------------------------------------------------------- 1 | 2 | # server domain name 3 | ServerName test.com 4 | 5 | # other domain names server responds to 6 | ServerAlias www.test.com 7 | 8 | # site code directory 9 | DocumentRoot /sites/test/html/public 10 | 11 | # accept php and html files as directory index 12 | DirectoryIndex index.php index.html 13 | 14 | # access and error logs 15 | ErrorLog /sites/test/logs/error_log 16 | CustomLog /sites/test/logs/access_log combined 17 | 18 | # custom error log format 19 | ErrorLogFormat "[%{cz}t] [%l] [client %a] %M, referer: %{Referer}i" 20 | 21 | # log 404 as errors 22 | LogLevel core:info 23 | 24 | # set which file apache will serve when url is a directory 25 | DirectoryIndex index.html index.php 26 | 27 | # fix http basic authentication 28 | SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 29 | 30 | # configure site code directory 31 | 32 | # Normally, if multiple Options could apply to a directory, then the most specific one is used and others are ignored; the options are not merged. (See how sections are merged.) 33 | # However if all the options on the Options directive are preceded by a + or - symbol, the options are merged. 34 | # Any options preceded by a + are added to the options currently in force, and any options preceded by a - are removed from the options currently in force. 35 | Options -ExecCGI +FollowSymLinks -SymLinksIfOwnerMatch -Includes -IncludesNOEXEC -Indexes -MultiViews 36 | 37 | # define what Options directives can be overriden in .htaccess 38 | AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch 39 | 40 | # set who can access the directory 41 | Require all granted 42 | 43 | 44 | # file php extension handled by php-fpm 45 | 46 | SetHandler "proxy:unix:/var/run/php-fpm8.sock|fcgi://localhost" 47 | 48 | 49 | # use SSL 50 | SSLEngine On 51 | 52 | # certificates 53 | SSLCertificateFile /sites/test/ssl/certificate.pem 54 | SSLCertificateKeyFile /sites/test/ssl/private.key 55 | SSLCACertificateFile /sites/config/ssl/certificate_authority.pem 56 | 57 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/js/fmt.js: -------------------------------------------------------------------------------- 1 | /* SPX - A simple profiler for PHP 2 | * Copyright (C) 2017-2020 Sylvain Lassaut 3 | * 4 | * This program is free software: you can redistribute it and/or modify 5 | * it under the terms of the GNU General Public License as published by 6 | * the Free Software Foundation; either version 3 of the License, or 7 | * (at your option) any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | 19 | 20 | import {round} from './?SPX_UI_URI=/js/math.js'; 21 | 22 | export function lpad(str, len, char) { 23 | str = str + ''; 24 | let d = len - str.length; 25 | if (d <= 0) { 26 | return str; 27 | } 28 | 29 | return char.repeat(d) + str; 30 | } 31 | 32 | export function date(d) { 33 | return d.getFullYear() 34 | + '-' + lpad(d.getMonth() + 1, 2, '0') 35 | + '-' + lpad(d.getDate(), 2, '0') 36 | + ' ' + lpad(d.getHours(), 2, '0') 37 | + ':' + lpad(d.getMinutes(), 2, '0') 38 | + ':' + lpad(d.getSeconds(), 2, '0') 39 | ; 40 | } 41 | 42 | export function quantity(n) { 43 | if (n >= 1000 * 1000 * 1000) { 44 | return round(n / (1000 * 1000 * 1000), 2).toFixed(2) + 'G'; 45 | } 46 | 47 | if (n >= 1000 * 1000) { 48 | return round(n / (1000 * 1000), 2).toFixed(2) + 'M'; 49 | } 50 | 51 | if (n >= 1000) { 52 | return round(n / 1000, 2).toFixed(2) + 'K'; 53 | } 54 | 55 | return round(n, 0); 56 | } 57 | 58 | export function pct(n) { 59 | return round(n * 100, 2).toFixed(2) + '%'; 60 | } 61 | 62 | export function time(n) { 63 | if (n >= 1000 * 1000 * 1000) { 64 | return round(n / (1000 * 1000 * 1000), 2).toFixed(2) + 's'; 65 | } 66 | 67 | if (n >= 1000 * 1000) { 68 | return round(n / (1000 * 1000), 2).toFixed(2) + 'ms'; 69 | } 70 | 71 | if (n >= 1000) { 72 | return round(n / (1000), 2).toFixed(2) + 'us'; 73 | } 74 | 75 | return round(n, 0) + 'ns'; 76 | } 77 | 78 | export function memory(n) { 79 | const abs = Math.abs(n); 80 | 81 | if (abs >= (1 << 30)) { 82 | return round(n / (1 << 30), 2).toFixed(2) + 'GB'; 83 | } 84 | 85 | if (abs >= (1 << 20)) { 86 | return round(n / (1 << 20), 2).toFixed(2) + 'MB'; 87 | } 88 | 89 | if (abs >= (1 << 10)) { 90 | return round(n / (1 << 10), 2).toFixed(2) + 'KB'; 91 | } 92 | 93 | return round(n, 0) + 'B'; 94 | } 95 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/js/dataTable.js: -------------------------------------------------------------------------------- 1 | /* SPX - A simple profiler for PHP 2 | * Copyright (C) 2017-2020 Sylvain Lassaut 3 | * 4 | * This program is free software: you can redistribute it and/or modify 5 | * it under the terms of the GNU General Public License as published by 6 | * the Free Software Foundation; either version 3 of the License, or 7 | * (at your option) any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | 19 | 20 | export function makeDataTable(containerId, options, rows) { 21 | let sort_col = 0; 22 | let sort_dir = -1; 23 | 24 | function getColumnValue(accessor, row) { 25 | if ($.type(accessor) === 'function') { 26 | return accessor(row); 27 | } 28 | 29 | return row[accessor]; 30 | } 31 | 32 | let container = $('#' + containerId); 33 | let render = () => { 34 | let html = ''; 35 | 36 | for (let i = 0; i < options.columns.length; i++) { 37 | let column = options.columns[i]; 38 | html += ``; 39 | } 40 | 41 | html += ''; 42 | 43 | rows.sort((a, b) => { 44 | a = getColumnValue(options.columns[sort_col].value, a); 45 | b = getColumnValue(options.columns[sort_col].value, b); 46 | 47 | return (a < b ? -1 : (a > b)) * sort_dir; 48 | }); 49 | 50 | for (let row of rows) { 51 | let url = options.makeRowUrl ? options.makeRowUrl(row) : null; 52 | html += ''; 53 | for (let column of options.columns) { 54 | let value = getColumnValue(column.value, row); 55 | if (column.format) { 56 | value = column.format(value); 57 | } 58 | 59 | if (url) { 60 | value = `${value}`; 61 | } 62 | 63 | html += ``; 64 | } 65 | 66 | html += ''; 67 | } 68 | 69 | html += '
${column.label}
${value}
'; 70 | 71 | container.append(html); 72 | container.find('th').click(e => { 73 | let current = $(e.target).index(); 74 | if (sort_col == current) { 75 | sort_dir *= -1; 76 | } 77 | 78 | sort_col = current; 79 | 80 | container.empty(); 81 | render(); 82 | }); 83 | } 84 | 85 | render(); 86 | } 87 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | # php 8.5.0 4 | image: 8ct8pus/apache-php-fpm-alpine:2.6.0 5 | # php 8.4.15 6 | #image: 8ct8pus/apache-php-fpm-alpine:2.5.3 7 | # php 8.4.13 8 | #image: 8ct8pus/apache-php-fpm-alpine:2.5.2 9 | # php 8.4.8 10 | #image: 8ct8pus/apache-php-fpm-alpine:2.5.1 11 | # php 8.4.5 12 | #image: 8ct8pus/apache-php-fpm-alpine:2.4.2 13 | # php 8.4.4 14 | #image: 8ct8pus/apache-php-fpm-alpine:2.4.1 15 | # php 8.4.1 16 | #image: 8ct8pus/apache-php-fpm-alpine:2.4.0 17 | # php 8.3.22 18 | #image: 8ct8pus/apache-php-fpm-alpine:2.3.4 19 | # php 8.3.17 20 | #image: 8ct8pus/apache-php-fpm-alpine:2.3.3 21 | # php 8.3.13 RC1 22 | #image: 8ct8pus/apache-php-fpm-alpine:2.3.2 23 | # php 8.3.7 - iconv bug fix 24 | #image: 8ct8pus/apache-php-fpm-alpine:2.3.1 25 | # php 8.3.7 26 | #image: 8ct8pus/apache-php-fpm-alpine:2.3.0 27 | # php 8.3.0 28 | #image: 8ct8pus/apache-php-fpm-alpine:2.2.0 29 | # php 8.2.11 with virtual hosts with selfsign 30 | #image: 8ct8pus/apache-php-fpm-alpine:2.1.3 31 | # php 8.2.10 with virtual hosts with selfsign 32 | #image: 8ct8pus/apache-php-fpm-alpine:2.1.2 33 | # php 8.2.9 with virtual hosts with selfsign 34 | #image: 8ct8pus/apache-php-fpm-alpine:2.1.1 35 | # php 8.2.8 with virtual hosts with selfsign 36 | #image: 8ct8pus/apache-php-fpm-alpine:2.1.0 37 | # php 8.2.8 with virtual hosts 38 | #image: 8ct8pus/apache-php-fpm-alpine:2.1.0 39 | # php 8.2.7 with virtual hosts 40 | #image: 8ct8pus/apache-php-fpm-alpine:2.0.12 41 | # php 8.2.5 with virtual hosts 42 | #image: 8ct8pus/apache-php-fpm-alpine:2.0.11 43 | # php 8.2.4 with virtual hosts 44 | #image: 8ct8pus/apache-php-fpm-alpine:2.0.10 45 | # php 8.2.3 with virtual hosts 46 | #image: 8ct8pus/apache-php-fpm-alpine:2.0.9 47 | # php 8.2.2 with virtual hosts 48 | #image: 8ct8pus/apache-php-fpm-alpine:2.0.8 49 | # php 8.2.1 with virtual hosts 50 | #image: 8ct8pus/apache-php-fpm-alpine:2.0.7 51 | # php 8.2.0 with virtual hosts 52 | #image: 8ct8pus/apache-php-fpm-alpine:2.0.6 53 | # php 8.1.12 with virtual hosts 54 | #image: 8ct8pus/apache-php-fpm-alpine:2.0.5 55 | # php 8.2.0 RC6 56 | #image: 8ct8pus/apache-php-fpm-alpine:1.4.4 57 | # php 8.1.10 58 | #image: 8ct8pus/apache-php-fpm-alpine:1.3.6 59 | # php 8.0.17 60 | #image: 8ct8pus/apache-php-fpm-alpine:1.2.8 61 | # php 7.4.21 62 | #image: 8ct8pus/apache-php-fpm-alpine:1.1.3 63 | # development image 64 | #image: apache-php-fpm-alpine:dev 65 | hostname: testing 66 | container_name: web 67 | ports: 68 | - 80:80 69 | - 443:443 70 | - 8025:8025 71 | volumes: 72 | # development directory 73 | - ./sites/:/sites/ 74 | # expose apache2 and php config to host 75 | - ./docker/etc/:/docker/etc/ 76 | # expose logs to host 77 | #- ./docker/log/:/var/log/ 78 | extra_hosts: 79 | # this is for linux users 80 | - "host.docker.internal:host-gateway" 81 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/js/layoutSplitter.js: -------------------------------------------------------------------------------- 1 | /* SPX - A simple profiler for PHP 2 | * Copyright (C) 2017-2020 Sylvain Lassaut 3 | * 4 | * This program is free software: you can redistribute it and/or modify 5 | * it under the terms of the GNU General Public License as published by 6 | * the Free Software Foundation; either version 3 of the License, or 7 | * (at your option) any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | 19 | let changeHandler = null; 20 | 21 | export function init() { 22 | let resizedElement = null; 23 | let dir = 1; 24 | let horizontal = true; 25 | let min = 0; 26 | let max = 0; 27 | let originalSize = 0; 28 | let originalPos = 0; 29 | 30 | $("div[data-layout-splitter-target]").mousedown((e) => { 31 | resizedElement = document.getElementById(e.target.getAttribute("data-layout-splitter-target")); 32 | dir = parseInt(e.target.getAttribute("data-layout-splitter-dir") || "1"); 33 | horizontal = "x" === (e.target.getAttribute("data-layout-splitter-axis") || "x"); 34 | min = Math.max(10, parseInt(e.target.getAttribute("data-layout-splitter-min"))); 35 | max = Math.round(0.9 * (horizontal ? resizedElement.parentNode.offsetWidth : resizedElement.parentNode.offsetHeight)); 36 | 37 | originalPos = horizontal ? e.originalEvent.clientX : e.originalEvent.clientY; 38 | originalSize = horizontal ? resizedElement.offsetWidth : resizedElement.offsetHeight; 39 | 40 | e.preventDefault(); 41 | }); 42 | 43 | $(window).mouseup((e) => { 44 | if (!resizedElement) { 45 | return; 46 | } 47 | 48 | e.preventDefault(); 49 | resizedElement = null; 50 | }); 51 | 52 | $(window).mousemove((e) => { 53 | if (!resizedElement) { 54 | return; 55 | } 56 | 57 | e.preventDefault(); 58 | 59 | const currentPos = horizontal ? e.originalEvent.clientX : e.originalEvent.clientY; 60 | const currentSize = horizontal ? resizedElement.offsetWidth : resizedElement.offsetHeight; 61 | 62 | let newSize = (originalSize + dir * (originalPos - currentPos)); 63 | 64 | if (newSize < min) { 65 | newSize = min; 66 | } 67 | 68 | if (newSize > max) { 69 | newSize = max; 70 | } 71 | 72 | if (newSize == currentSize) { 73 | return; 74 | } 75 | 76 | if (horizontal) { 77 | resizedElement.style.width = newSize + 'px'; 78 | } else { 79 | resizedElement.style.height = newSize + 'px'; 80 | } 81 | 82 | changeHandler && changeHandler(); 83 | }); 84 | } 85 | 86 | export function change(handler) { 87 | changeHandler = handler; 88 | } 89 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/css/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 99%; 3 | } 4 | 5 | body { 6 | font-family: monospace; 7 | margin: 2px; 8 | padding: 0; 9 | height: 100%; 10 | } 11 | 12 | * { 13 | color: #089; 14 | } 15 | 16 | body, #colorscheme-panel { 17 | background: #023; 18 | background: -webkit-linear-gradient(#023, #000); 19 | background: -o-linear-gradient(#023, #000); 20 | background: -moz-linear-gradient(#023, #000); 21 | background: linear-gradient(#023, #000); 22 | background-repeat: no-repeat; 23 | background-attachment: fixed; 24 | } 25 | 26 | form#config label { 27 | display: inline-block; 28 | width: 300px; 29 | margin-right: 20px; 30 | margin-bottom: 30px; 31 | text-align: right; 32 | vertical-align: top; 33 | } 34 | 35 | form#config em { 36 | display: inline-block; 37 | vertical-align: top; 38 | width: 300px; 39 | margin-left: 10px; 40 | margin-bottom: 20px; 41 | } 42 | 43 | form#config input, 44 | form#config textarea { 45 | vertical-align: top; 46 | border: 1px solid #044; 47 | background-color: #000; 48 | } 49 | 50 | form#config select { 51 | margin-bottom: 10px; 52 | background-color: #000; 53 | width: 280px; 54 | } 55 | 56 | table.data_table { 57 | border-collapse: collapse; 58 | border-spacing: 0; 59 | margin: auto; 60 | margin-top: 5px; 61 | } 62 | 63 | table.data_table tr { 64 | cursor: pointer; 65 | } 66 | 67 | table.data_table th, 68 | table.data_table td { 69 | border: 1px solid #044; 70 | margin: 0; 71 | padding: 3px; 72 | } 73 | 74 | table.data_table th.data_table-sort, 75 | table.data_table th:hover { 76 | background-color: #023; 77 | } 78 | 79 | table.data_table tbody > tr:hover { 80 | background-color: #033; 81 | } 82 | 83 | table.data_table td.breakable-text { 84 | word-break: break-all; 85 | } 86 | 87 | table.data_table td > a { 88 | display: block; 89 | text-decoration: none; 90 | } 91 | 92 | #init-report { 93 | position: absolute; 94 | left: 50%; 95 | } 96 | 97 | #init-report > div { 98 | position: relative; 99 | left: -50%; 100 | top: 200px; 101 | border: solid 1px #0aa; 102 | background-color: #023; 103 | width: 400px; 104 | height: 200px; 105 | padding: 10px; 106 | vertical-align: center; 107 | text-align: center; 108 | } 109 | 110 | #init-report div.progress { 111 | display: none; 112 | width: 100%; 113 | height: 20px; 114 | border: solid 1px #099; 115 | padding: 1px; 116 | } 117 | 118 | #init-report div.progress > div { 119 | height: 100%; 120 | background-color: #0d0; 121 | } 122 | 123 | #overview, 124 | #timeline { 125 | cursor: -webkit-grab; 126 | cursor:-moz-grab; 127 | } 128 | 129 | #overview:active, 130 | #timeline:active { 131 | cursor: -webkit-grabbing; 132 | cursor:-moz-grabbing; 133 | } 134 | 135 | .widget { 136 | border: solid 1px #022; 137 | } 138 | 139 | .widget:hover { 140 | border: solid 1px #0aa; 141 | } 142 | 143 | .visualization { 144 | -webkit-touch-callout: none; 145 | -webkit-user-select: none; 146 | -khtml-user-select: none; 147 | -moz-user-select: none; 148 | -ms-user-select: none; 149 | user-select: none; 150 | } 151 | 152 | #metric-selector select { 153 | background-color: #000; 154 | } 155 | 156 | #colorscheme-panel { 157 | display: none; 158 | position: absolute; 159 | top: 24px; 160 | width: 350px; 161 | padding: 6px; 162 | } 163 | 164 | #colorscheme-panel ol { 165 | list-style-type: none; 166 | padding: 0; 167 | margin: 0; 168 | } 169 | 170 | #colorscheme-panel hr { 171 | border-color: #089; 172 | } 173 | 174 | #colorscheme-panel button, #colorscheme-panel input, #colorscheme-panel textarea { 175 | font-family: monospace; 176 | font-size: 12px; 177 | background-color: #000; 178 | border: 1px solid #0898; 179 | } 180 | 181 | #colorscheme-panel .category { 182 | margin: 10px 0 0; 183 | } 184 | 185 | #colorscheme-panel input[name="label"] { 186 | width: 181px; 187 | } 188 | 189 | #colorscheme-panel textarea[name="patterns"] { 190 | width: calc(100% - 6px); 191 | } 192 | 193 | #colorscheme-panel .jscolor { 194 | width: 45px; 195 | height: 15px; 196 | } 197 | 198 | #category-legend { 199 | display: none; 200 | } 201 | 202 | #flatprofile table { 203 | border-collapse: collapse; 204 | border-spacing: 0; 205 | margin: 0; 206 | } 207 | 208 | #flatprofile table th, 209 | #flatprofile table td { 210 | border: 1px solid #044; 211 | margin: 0; 212 | padding-left: 2px; 213 | padding-right: 2px; 214 | } 215 | 216 | #flatprofile table th.sortable { 217 | cursor: pointer; 218 | } 219 | 220 | #flatprofile table th.sort, 221 | #flatprofile table th.sortable:hover { 222 | background-color: #023; 223 | } 224 | 225 | #flatprofile table td { 226 | text-align: right; 227 | } 228 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/js/math.js: -------------------------------------------------------------------------------- 1 | /* SPX - A simple profiler for PHP 2 | * Copyright (C) 2017-2020 Sylvain Lassaut 3 | * 4 | * This program is free software: you can redistribute it and/or modify 5 | * it under the terms of the GNU General Public License as published by 6 | * the Free Software Foundation; either version 3 of the License, or 7 | * (at your option) any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | 19 | 20 | export function round(n, d) { 21 | const scale = Math.pow(10, d || 0); 22 | 23 | return Math.round(n * scale) / scale; 24 | } 25 | 26 | export function bound(a, low, up) { 27 | return Math.max(low || 0, Math.min(a, up || 1)); 28 | } 29 | 30 | export function lerp(a, b, dist) { 31 | dist = bound(dist); 32 | 33 | return a * (1 - dist) + b * dist; 34 | } 35 | 36 | export function lerpDist(a, b, value) { 37 | return (value - a) / (b - a); 38 | } 39 | 40 | export function dist(a, b) { 41 | return Math.abs(a - b); 42 | } 43 | 44 | export class Vec3 { 45 | 46 | constructor(x, y, z) { 47 | this.x = x; 48 | this.y = y; 49 | this.z = z; 50 | } 51 | 52 | copy() { 53 | return new Vec3( 54 | this.x, 55 | this.y, 56 | this.z 57 | ); 58 | } 59 | 60 | bound(low, up) { 61 | bound(this.x, low, up); 62 | bound(this.y, low, up); 63 | bound(this.z, low, up); 64 | 65 | return this; 66 | } 67 | 68 | mult(v) { 69 | this.x *= v; 70 | this.y *= v; 71 | this.z *= v; 72 | 73 | return this; 74 | } 75 | 76 | toHTMLColor() { 77 | const c = this.copy().bound(); 78 | 79 | return 'rgb(' + [ 80 | parseInt(c.x * 255), 81 | parseInt(c.y * 255), 82 | parseInt(c.z * 255), 83 | ].join(',') + ')'; 84 | } 85 | 86 | static createFromHTMLColor(htmlColor) { 87 | const matches = /rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/.exec(htmlColor); 88 | 89 | return this.createFromRGB888( 90 | matches[1], 91 | matches[2], 92 | matches[3] 93 | ); 94 | } 95 | 96 | static createFromRGB888(r, g, b) { 97 | const v = new Vec3( 98 | r / 255, 99 | g / 255, 100 | b / 255, 101 | ); 102 | 103 | return v.bound(); 104 | } 105 | 106 | static lerp(a, b, dist) { 107 | return new Vec3( 108 | lerp(a.x, b.x, dist), 109 | lerp(a.y, b.y, dist), 110 | lerp(a.z, b.z, dist) 111 | ); 112 | } 113 | 114 | static lerpPath(vectors, dist) { 115 | dist = bound(dist); 116 | 117 | const span = 1 / (vectors.length - 1); 118 | const firstIdx = bound(parseInt(dist / span), 0, vectors.length - 2); 119 | 120 | return this.lerp( 121 | vectors[firstIdx], 122 | vectors[firstIdx + 1], 123 | (dist - firstIdx * span) / span 124 | ); 125 | } 126 | } 127 | 128 | export class Range { 129 | 130 | constructor(begin, end) { 131 | if (begin > end) { 132 | throw new Error('Invalid range: ' + begin + ' ' + end); 133 | } 134 | 135 | this.begin = begin; 136 | this.end = end; 137 | } 138 | 139 | copy() { 140 | return new Range(this.begin, this.end); 141 | } 142 | 143 | length() { 144 | return this.end - this.begin; 145 | } 146 | 147 | center() { 148 | return (this.begin + this.end) / 2; 149 | } 150 | 151 | lerp(dist) { 152 | return lerp(this.begin, this.end, dist); 153 | } 154 | 155 | lerpDist(value) { 156 | return lerpDist(this.begin, this.end, value); 157 | } 158 | 159 | intersect(other) { 160 | if (!this.overlaps(other)) { 161 | throw new Error('Ranges do not overlap'); 162 | } 163 | 164 | this.begin = Math.max(this.begin, other.begin); 165 | this.end = Math.min(this.end, other.end); 166 | 167 | return this; 168 | } 169 | 170 | subRange(ratio, num) { 171 | const width = ratio * this.length(); 172 | 173 | return new Range( 174 | Math.max(this.begin, this.begin + width * num), 175 | Math.min(this.end, this.begin + width * (num + 1)) 176 | ); 177 | } 178 | 179 | scale(factor) { 180 | this.begin *= factor; 181 | this.end *= factor; 182 | 183 | return this; 184 | } 185 | 186 | shift(dist) { 187 | this.begin += dist; 188 | this.end += dist; 189 | 190 | return this; 191 | } 192 | 193 | shiftBegin(dist) { 194 | this.begin = Math.min(this.begin + dist, this.end); 195 | 196 | return this; 197 | } 198 | 199 | shiftEnd(dist) { 200 | this.end = Math.max(this.end + dist, this.begin); 201 | 202 | return this; 203 | } 204 | 205 | bound(low, up) { 206 | low = low || 0; 207 | up = up || 1; 208 | 209 | this.begin = bound(this.begin, low, up); 210 | this.end = bound(this.end, low, up); 211 | 212 | if (this.begin > this.end) { 213 | this.begin = low; 214 | this.end = up; 215 | } 216 | 217 | return this; 218 | } 219 | 220 | containsValue(val) { 221 | return this.begin <= val && val <= this.end; 222 | } 223 | 224 | contains(other) { 225 | return this.begin <= other.begin && other.end <= this.end; 226 | } 227 | 228 | isContainedBy(other) { 229 | return other.contains(this); 230 | } 231 | 232 | overlaps(other) { 233 | return !(this.end < other.begin || other.end < this.begin); 234 | } 235 | 236 | sub(other) { 237 | if (other.contains(this)) { 238 | this.end = this.begin; 239 | return this; 240 | } 241 | 242 | if (this.contains(other)) { 243 | this.end -= other.length(); 244 | } 245 | 246 | if (!this.overlaps(other)) { 247 | return this; 248 | } 249 | 250 | if (this.containsValue(other.begin)) { 251 | this.end = other.begin; 252 | } else if (this.containsValue(other.end)) { 253 | this.begin = other.end; 254 | } 255 | 256 | return this; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /include/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "" 4 | echo "Start container web server..." 5 | 6 | # check if we should expose apache to host 7 | # /docker/etc/ must be set in docker-compose.yml 8 | if [ -d /docker/etc/ ]; 9 | then 10 | echo "Expose apache to host..." 11 | sleep 3 12 | 13 | # check if config backup exists 14 | if [ ! -d /etc/apache2.bak/ ]; 15 | then 16 | # create config backup 17 | echo "Expose apache to host - backup container config" 18 | cp -r /etc/apache2/ /etc/apache2.bak/ 19 | fi 20 | 21 | # check if config exists on host 22 | if [ -z "$(ls -A /docker/etc/apache2/ 2> /dev/null)" ]; 23 | then 24 | # config doesn't exist on host 25 | echo "Expose apache to host - no host config" 26 | 27 | # check if config backup exists 28 | if [ -d /etc/apache2.bak/ ]; 29 | then 30 | # restore config from backup 31 | echo "Expose apache to host - restore config from backup" 32 | rm /etc/apache2/ 2> /dev/null 33 | cp -r /etc/apache2.bak/ /etc/apache2/ 34 | fi 35 | 36 | # copy config to host 37 | echo "Expose apache to host - copy config to host" 38 | cp -r /etc/apache2/ /docker/etc/ 39 | else 40 | echo "Expose apache to host - config exists on host" 41 | fi 42 | 43 | # create symbolic link so host config is used 44 | echo "Expose apache to host - create symlink" 45 | rm -rf /etc/apache2/ 2> /dev/null 46 | ln -s /docker/etc/apache2 /etc/apache2 47 | 48 | echo "Expose apache to host - OK" 49 | fi 50 | 51 | # check if sites config does not exist 52 | # it happens when docker-compose.yml mounts sites dir on the host 53 | if [ ! -d /sites/config/ ]; 54 | then 55 | # copy default config from the backup 56 | cp -r /sites.bak/config/ /sites/config/ 57 | 58 | # check if localhost does not exist 59 | if [ ! -d /sites/localhost/ ]; 60 | then 61 | # copy localhost from the backup 62 | cp -rp /sites.bak/localhost/ /sites/localhost/ 63 | fi 64 | 65 | # check if test does not exist 66 | if [ ! -d /sites/test/ ]; 67 | then 68 | # copy test from the backup 69 | cp -rp /sites.bak/test/ /sites/test/ 70 | fi 71 | fi 72 | 73 | # check if SSL certificate authority does not exist 74 | if [ ! -e /sites/config/ssl/certificate_authority.pem ]; 75 | then 76 | # https://stackoverflow.com/questions/7580508/getting-chrome-to-accept-self-signed-localhost-certificate 77 | echo "Generate SSL certificate authority..." 78 | 79 | selfsign authority /sites/config/ssl 80 | 81 | echo "Generate SSL certificate authority - OK" 82 | fi 83 | 84 | # check if localhost config exists 85 | if [ -d /sites/localhost/ ]; 86 | then 87 | # check if localhost ssl certificate exists 88 | if [ ! -e /sites/localhost/ssl/certificate.pem ]; 89 | then 90 | # create certificate 91 | selfsign certificate /sites/localhost/ssl localhost /sites/config/ssl 92 | fi 93 | fi 94 | 95 | # check if test.com config exists 96 | if [ -d /sites/test/ ]; 97 | then 98 | # check if test.com ssl certificate exists 99 | if [ ! -e /sites/test/ssl/certificate.pem ]; 100 | then 101 | # create certificate 102 | selfsign certificate /sites/test/ssl test.com,www.test.com /sites/config/ssl 103 | fi 104 | fi 105 | 106 | # check if we should expose php to host 107 | if [ -d /docker/etc/ ]; 108 | then 109 | echo "Expose php to host..." 110 | sleep 3 111 | 112 | # check if config backup exists 113 | if [ ! -d /etc/php85.bak/ ]; 114 | then 115 | # create config backup 116 | echo "Expose php to host - backup container config" 117 | cp -r /etc/php85/ /etc/php85.bak/ 118 | fi 119 | 120 | # check if php config exists on host 121 | if [ -z "$(ls -A /docker/etc/php85/ 2> /dev/null)" ]; 122 | then 123 | # config doesn't exist on host 124 | echo "Expose php to host - no host config" 125 | 126 | # check if config backup exists 127 | if [ -d /etc/php85.bak/ ]; 128 | then 129 | # restore config from backup 130 | echo "Expose php to host - restore config from backup" 131 | rm /etc/php85/ 2> /dev/null 132 | cp -r /etc/php85.bak/ /etc/php8/ 133 | fi 134 | 135 | # copy config to host 136 | echo "Expose php to host - copy config to host" 137 | cp -r /etc/php85/ /docker/etc/ 138 | else 139 | echo "Expose php to host - config exists on host" 140 | fi 141 | 142 | # create symbolic link so host config is used 143 | echo "Expose php to host - create symlink" 144 | rm -rf /etc/php85/ 2> /dev/null 145 | ln -s /docker/etc/php85 /etc/php85 146 | 147 | echo "Expose php to host - OK" 148 | fi 149 | 150 | # clean log files 151 | truncate -s 0 /sites/*/logs/access_log 2> /dev/null 152 | truncate -s 0 /sites/*/logs/error_log 2> /dev/null 153 | truncate -s 0 /var/log/ssl_request.log 2> /dev/null 154 | truncate -s 0 /sites/localhost/logs/xdebug.log 2> /dev/null 155 | 156 | # allow xdebug to write to log file 157 | chmod 666 /var/log/xdebug.log 2> /dev/null 158 | 159 | echo "Start mailpit" 160 | mailpit 1> /dev/null & 161 | 162 | # start php-fpm 163 | php-fpm85 164 | 165 | # sleep 166 | sleep 2 167 | 168 | # check if php-fpm is running 169 | if pgrep -x php-fpm85 > /dev/null 170 | then 171 | echo "Start php-fpm - OK" 172 | else 173 | echo "Start php-fpm - FAILED" 174 | exit 1 175 | fi 176 | 177 | echo "-------------------------------------------------------" 178 | 179 | # start apache 180 | httpd -k start 181 | 182 | # check if apache is running 183 | if pgrep -x httpd > /dev/null 184 | then 185 | echo "Start container web server - OK - ready for connections" 186 | else 187 | echo "Start container web server - FAILED" 188 | exit 1 189 | fi 190 | 191 | echo "-------------------------------------------------------" 192 | 193 | stop_container() 194 | { 195 | echo "" 196 | echo "Stop container web server... - received SIGTERM signal" 197 | echo "Stop container web server - OK" 198 | exit 0 199 | } 200 | 201 | # catch termination signals 202 | # https://unix.stackexchange.com/questions/317492/list-of-kill-signals 203 | trap stop_container SIGTERM 204 | 205 | 206 | restart_processes() 207 | { 208 | sleep 0.5 209 | 210 | # test php-fpm config 211 | if php-fpm85 -t 212 | then 213 | # restart php-fpm 214 | echo "Restart php-fpm..." 215 | killall php-fpm85 > /dev/null 216 | php-fpm85 217 | 218 | # check if php-fpm is running 219 | if pgrep -x php-fpm85 > /dev/null 220 | then 221 | echo "Restart php-fpm - OK" 222 | else 223 | echo "Restart php-fpm - FAILED" 224 | fi 225 | else 226 | echo "Restart php-fpm - FAILED - syntax error" 227 | fi 228 | 229 | # test apache config 230 | if httpd -t 231 | then 232 | # restart apache 233 | echo "Restart apache..." 234 | httpd -k restart 235 | 236 | # check if apache is running 237 | if pgrep -x httpd > /dev/null 238 | then 239 | echo "Restart apache - OK" 240 | else 241 | echo "Restart apache - FAILED" 242 | fi 243 | else 244 | echo "Restart apache - FAILED - syntax error" 245 | fi 246 | } 247 | 248 | # infinite loop, will only stop on termination signal or deletion of sites/config 249 | while [ -d /sites/config/ ] 250 | do 251 | # restart apache and php-fpm if any file in /etc/apache2 or /etc/php85 changes 252 | inotifywait --quiet --event modify,create,delete --timeout 3 --recursive /etc/apache2/ /etc/php85/ /sites/config/ && restart_processes 253 | done 254 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # docker apache php-fpm alpine 2 | 3 | ![Docker image size (latest semver)](https://img.shields.io/docker/image-size/8ct8pus/apache-php-fpm-alpine?sort=semver) 4 | ![Docker image pulls](https://img.shields.io/docker/pulls/8ct8pus/apache-php-fpm-alpine) 5 | [image on dockerhub](https://hub.docker.com/r/8ct8pus/apache-php-fpm-alpine) 6 | 7 | Apache php-fpm Alpine is a lightweight (200MB) Docker web server image that combines `Apache` HTTP Server with `PHP-FPM`, built on top of Alpine Linux. Designed specifically for PHP developers, this image offers a minimal footprint while providing a fully functional and configurable environment for PHP development and testing. It supports multiple PHP versions (including PHP 8.5), includes SSL support out of the box, and facilitates easy virtual host management. This container is ideal for rapid development workflows, offering hot reload capabilities for configuration changes and seamless integration with your local development domains. 8 | 9 | - Apache 2.4.65 with SSL 10 | - php-fpm 8.5.0, 8.4, 8.3, 8.2, 8.1, 8.0 or 7.4 11 | - Xdebug 3.4.7 - debugger and profiler 12 | - composer 2.9.2 13 | - [SPX prolifer 0.4.22](https://github.com/NoiseByNorthwest/php-spx) 14 | - zsh 5.9 15 | - Alpine 3.22.2 using edge repositories 16 | 17 | ## cool features 18 | 19 | - php along with the most commonly used extensions 20 | - Just works with any domain name 21 | - Support for multiple virtual hosts 22 | - https is configured out of the box 23 | - Apache and php configuration files are exposed on the host for easy editing 24 | - All changes to configuration files are automatically applied (hot reload) 25 | - Xdebug is configured for step by step debugging and profiling 26 | - Profile php code with SPX or Xdebug 27 | - Trap emails using [mailpit](https://github.com/axllent/mailpit) 28 | 29 | ## getting started 30 | 31 | - download [`docker-compose.yml`](https://github.com/8ctopus/apache-php-fpm-alpine/blob/master/docker-compose.yml) 32 | - select php version: `8.5` is the default flavor, to use php `8.4`, `8.3`, `8.2`, `8.1`, `8.0` or `7.4`, select the image in `docker-compose.yml` 33 | - start `Docker Desktop` and run `docker-compose up` 34 | - open the browser at [`http://localhost/`](http://localhost/) 35 | - update the source code in `sites/localhost/html/public/` 36 | 37 | _Note_: On Windows [file changes notification to the container doesn't work with the WSL 2 engine](https://github.com/8ctopus/apache-php-fpm-alpine/issues), you need to use the `Hyper-V` engine. Uncheck `Use WSL 2 based engine`. What this means, is that files you update on Windows are not updated inside the container unless you use `Hyper-V`. 38 | 39 | _Note_: If you need a fully-fledged development environment, checkout the [php sandbox](https://github.com/8ctopus/php-sandbox) project. 40 | 41 | ## use container 42 | 43 | Starting the container with `docker-compose` offers all functionalities. 44 | 45 | ```sh 46 | # start container in detached mode on Windows in cmd 47 | start /B docker-compose up 48 | 49 | # start container in detached mode on linux, mac and mintty 50 | docker-compose up & 51 | 52 | # view logs 53 | docker-compose logs -f 54 | 55 | # stop container 56 | docker-compose stop 57 | 58 | # delete container 59 | docker-compose down 60 | ``` 61 | 62 | Alternatively the container can also be started with `docker run`. 63 | 64 | ```sh 65 | # php 8.5 66 | docker run -p 80:80 -p 443:443 --name web 8ct8pus/apache-php-fpm-alpine:2.6.0 67 | 68 | # php 8.4 69 | docker run -p 80:80 -p 443:443 --name web 8ct8pus/apache-php-fpm-alpine:2.6.0 70 | 71 | # php 8.3 72 | docker run -p 80:80 -p 443:443 --name web 8ct8pus/apache-php-fpm-alpine:2.3.4 73 | 74 | # php 8.2 75 | docker run -p 80:80 -p 443:443 --name web 8ct8pus/apache-php-fpm-alpine:2.1.3 76 | 77 | CTRL-C to stop 78 | ``` 79 | 80 | ## access sites 81 | 82 | There are 2 sites you can access from your browser 83 | 84 | http(s)://localhost/ 85 | http(s)://(www.)test.com/ 86 | 87 | The source code is located inside the `sites/*/html/public/` directories. 88 | 89 | ## domain names 90 | 91 | Setting a domain name is done by using virtual hosts. The virtual hosts configuration files are located in `sites/config/vhosts/`. By default, `localhost` and `test.com` are already defined as virtual hosts. 92 | 93 | For your browser to resolve `test.com`, add this line to your system's host file. Editing the file requires administrator privileges.\ 94 | \ 95 | On Windows: `C:\Windows\System32\drivers\etc\hosts`\ 96 | Linux and Mac: `/etc/hosts` 97 | 98 | 127.0.0.1 test.com www.test.com 99 | 100 | ## https 101 | 102 | A self-signed https certificate is already configured for `localhost` and `test.com`.\ 103 | To remove "Your connection is not private" nag screens, import the certificate authority file `sites/config/ssl/certificate_authority.pem` to your computer's Trusted Root Certification Authorities then restart your browser. 104 | 105 | In Windows, open `certmgr.msc` > click `Trusted Root Certification Authorities`, then right click on that folder and select `Import...` under `All Tasks`. 106 | 107 | On Linux and Mac: \[fill blank\] 108 | 109 | For newly created domains, you will need to create the SSL certificate: 110 | 111 | ```sh 112 | docker-exec -it web zsh 113 | selfsign certificate /sites/domain/ssl domain.com,www.domain.com,api.domain.com /sites/config/ssl 114 | ``` 115 | 116 | _Note_: Importing the certificate authority creates a security risk since all certificates issued by this new authority are shown as perfectly valid in your browsers. 117 | 118 | ## Xdebug debugger 119 | 120 | This repository is configured to debug php code in Visual Studio Code. To start debugging, open the VSCode workspace then select `Run > Start debugging` then open the site in the browser.\ 121 | The default config is to stop on entry which stops at the first line in the file. To only stop on breakpoints, set `stopOnEntry` to `false` in `.vscode/launch.json`. 122 | 123 | For other IDEs, set the Xdebug debugging port to `9001`. 124 | 125 | To troubleshoot debugger issues, check the `sites/localhost/logs/xdebug.log` file. 126 | 127 | If `host.docker.internal` does not resolve within the container, update the xdebug client host within `docker/etc/php/conf.d/xdebug.ini` to the docker host ip address. 128 | 129 | ```ini 130 | xdebug.client_host = 192.168.65.2 131 | ``` 132 | 133 | ## Code profiling 134 | 135 | Code profiling comes in 2 variants. 136 | 137 | _Note_: Disable Xdebug debugger `xdebug.remote_enable` for accurate measurements. 138 | 139 | ## Xdebug 140 | 141 | To start profiling, add the `XDEBUG_PROFILE` variable to the request as a GET, POST or COOKIE. 142 | 143 | http://localhost/?XDEBUG_PROFILE 144 | 145 | Profiles are stored in the `log` directory and can be analyzed with tools such as [webgrind](https://github.com/jokkedk/webgrind). 146 | 147 | ## SPX 148 | 149 | - Access the [SPX control panel](http://localhost/?SPX_KEY=dev&SPX_UI_URI=/) 150 | - Check checkbox `Whether to enable SPX profiler for your current browser session. No performance impact for other clients.` 151 | - Run the script to profile 152 | - Refresh the SPX control panel tab and the report will be available at the bottom of the screen. Click it to show the report in a new tab. 153 | 154 | ## Mailpit 155 | 156 | Mailpit catches outgoing emails and displays them in a web interface. 157 | 158 | http://localhost:8025 159 | 160 | ## access container command line 161 | 162 | ```sh 163 | docker exec -it web zsh 164 | ``` 165 | 166 | ## install more php extensions 167 | 168 | ```sh 169 | docker exec -it web zsh 170 | 171 | apk add php82- 172 | ``` 173 | 174 | ## extend the docker image 175 | 176 | Let's extend this docker image by adding the `php-curl` extension. 177 | 178 | ```sh 179 | docker-compose up --detach 180 | docker exec -it web zsh 181 | apk add php-curl 182 | exit 183 | 184 | docker-compose stop 185 | docker commit web apache-php-fpm-alpine-curl:dev 186 | ``` 187 | 188 | To use newly created image, update the image reference in `docker-compose.yml`. 189 | 190 | ## update docker image 191 | 192 | When you update the docker image version in `docker-compose.yml`, it's important to know that the existing configuration in the `docker` dir may cause problems.\ 193 | To solve all problems, backup the existing dir then delete it. 194 | 195 | ## build development image 196 | 197 | ```sh 198 | docker build -t apache-php-fpm-alpine:dev . 199 | ``` 200 | 201 | - update `docker-compose.yml` and uncomment the development image 202 | 203 | ```yaml 204 | services: 205 | web: 206 | # development image 207 | image: apache-php-fpm-alpine:dev 208 | ``` 209 | 210 | ## more info on php-fpm 211 | 212 | https://php-fpm.org/about/ 213 | 214 | ## build the docker image 215 | 216 | _Note_: This is only for the project maintainer. 217 | 218 | ```sh 219 | # build php spx module 220 | ./php-spx/build.sh 221 | 222 | # bump version 223 | 224 | # build local image 225 | docker build --no-cache -t 8ct8pus/apache-php-fpm-alpine:2.6.0 . 226 | 227 | # test local image 228 | 229 | # push image to docker hub 230 | docker push 8ct8pus/apache-php-fpm-alpine:2.6.0 231 | ``` 232 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/js/utils.js: -------------------------------------------------------------------------------- 1 | /* SPX - A simple profiler for PHP 2 | * Copyright (C) 2017-2020 Sylvain Lassaut 3 | * 4 | * This program is free software: you can redistribute it and/or modify 5 | * it under the terms of the GNU General Public License as published by 6 | * the Free Software Foundation; either version 3 of the License, or 7 | * (at your option) any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | 19 | import * as math from './?SPX_UI_URI=/js/math.js'; 20 | 21 | export function getCookieVar(name) { 22 | let m = document.cookie.match(new RegExp('(^|\\b)' + name + '=([^;]+)')); 23 | 24 | return m ? m[2] : null; 25 | } 26 | 27 | export function setCookieVar(name, value) { 28 | document.cookie = name + '=' + value + '; expires=Thu, 31 Dec 2037 23:59:59 UTC; path=/'; 29 | } 30 | 31 | export function truncateFunctionName(str, max) { 32 | if (str.length < max) { 33 | return str; 34 | } 35 | 36 | return str.slice(0, max / 2 - 1) + '…' + str.slice(-max / 2); 37 | } 38 | 39 | function process(func, async, delay) { 40 | if (async || false) { 41 | setTimeout(func, delay || 0); 42 | } else { 43 | func(); 44 | } 45 | } 46 | 47 | export function processPipeline(calls, async, delay) { 48 | calls = calls.slice(0); 49 | calls.reverse(); 50 | let ret; 51 | 52 | function makeNextCall() { 53 | if (calls.length == 0) { 54 | return; 55 | } 56 | 57 | let call = calls.pop(); 58 | ret = call(ret); 59 | 60 | if (calls.length > 0) { 61 | process(makeNextCall, async, delay); 62 | } 63 | } 64 | 65 | process(makeNextCall, async, 0); 66 | } 67 | 68 | export function processCallChain(calls, async, delay) { 69 | calls = calls.slice(0); 70 | calls.reverse(); 71 | 72 | function makeNextCall() { 73 | if (calls.length == 0) { 74 | return; 75 | } 76 | 77 | let call = calls.pop(); 78 | 79 | process(() => { 80 | call(makeNextCall); 81 | }, async, delay || 0); 82 | } 83 | 84 | makeNextCall(); 85 | } 86 | 87 | export function processChunksAsync(from, to, chunkSize, chunkProcessor, done, delay) { 88 | if (chunkSize < 1) { 89 | chunkSize = chunkSize * (to - from); 90 | } 91 | 92 | chunkSize = math.bound(Math.round(chunkSize), 1, to - from); 93 | 94 | let chunks = []; 95 | 96 | while (1) { 97 | chunks.push([from, Math.min(to, from + chunkSize)]); 98 | if (from + chunkSize >= to) { 99 | break; 100 | } 101 | 102 | from += chunkSize; 103 | } 104 | 105 | chunks.reverse(); 106 | 107 | function processChunk() { 108 | if (chunks.length == 0) { 109 | done(); 110 | 111 | return; 112 | } 113 | 114 | let chunk = chunks.pop(); 115 | chunkProcessor(chunk[0], chunk[1]); 116 | setTimeout(processChunk, delay || 0); 117 | } 118 | 119 | setTimeout(processChunk, 0); 120 | } 121 | 122 | export class PackedRecordArray { 123 | 124 | constructor(fields, size) { 125 | this.typesInfo = { 126 | 'int8': { 127 | arrayType: Int8Array, 128 | }, 129 | 'int32': { 130 | arrayType: Int32Array, 131 | }, 132 | 'float32': { 133 | arrayType: Float32Array, 134 | }, 135 | 'float64': { 136 | arrayType: Float64Array, 137 | }, 138 | }; 139 | 140 | this.fields = []; 141 | this.elemSize = 0; 142 | for (let fieldName in fields) { 143 | const type = fields[fieldName]; 144 | if (!(type in this.typesInfo)) { 145 | throw new Error('Unsupported type: ' + type); 146 | } 147 | 148 | const typeInfo = this.typesInfo[type]; 149 | this.fields.push({ 150 | name: fieldName, 151 | typeInfo: typeInfo, 152 | }); 153 | 154 | this.elemSize += typeInfo.arrayType.BYTES_PER_ELEMENT; 155 | } 156 | 157 | this.fields.sort( 158 | (a, b) => b.typeInfo.arrayType.BYTES_PER_ELEMENT - a.typeInfo.arrayType.BYTES_PER_ELEMENT 159 | ); 160 | 161 | const alignment = this.fields[0].typeInfo.arrayType.BYTES_PER_ELEMENT; 162 | 163 | this.elemSize = Math.ceil(this.elemSize / alignment) * alignment; 164 | this.size = size; 165 | 166 | this.buffer = new ArrayBuffer(this.size * this.elemSize); 167 | 168 | this.fieldIndexes = {}; 169 | let currentIdx = 0; 170 | let currentOffset = 0; 171 | for (let field of this.fields) { 172 | this.fieldIndexes[field.name] = currentIdx++; 173 | 174 | field.typeElemSize = this.elemSize / field.typeInfo.arrayType.BYTES_PER_ELEMENT; 175 | field.typeOffset = currentOffset / field.typeInfo.arrayType.BYTES_PER_ELEMENT; 176 | field.typeArray = new field.typeInfo.arrayType(this.buffer); 177 | 178 | currentOffset += field.typeInfo.arrayType.BYTES_PER_ELEMENT; 179 | } 180 | } 181 | 182 | getSize() { 183 | return this.size; 184 | } 185 | 186 | setElement(idx, obj) { 187 | const elemOffset = idx * this.elemSize; 188 | 189 | for (let field of this.fields) { 190 | field.typeArray[idx * field.typeElemSize + field.typeOffset] = obj[field.name] || 0; 191 | } 192 | } 193 | 194 | setElementFieldValue(idx, fieldName, fieldValue) { 195 | if (!(fieldName in this.fieldIndexes)) { 196 | throw new Error('Unknown field: ' + fieldName); 197 | } 198 | 199 | const field = this.fields[this.fieldIndexes[fieldName]]; 200 | 201 | field.typeArray[idx * field.typeElemSize + field.typeOffset] = fieldValue || 0; 202 | } 203 | 204 | getElement(idx) { 205 | const elemOffset = idx * this.elemSize; 206 | 207 | let obj = {}; 208 | for (let field of this.fields) { 209 | obj[field.name] = field.typeArray[idx * field.typeElemSize + field.typeOffset]; 210 | } 211 | 212 | return obj; 213 | } 214 | 215 | getElementFieldValue(idx, fieldName) { 216 | if (!(fieldName in this.fieldIndexes)) { 217 | throw new Error('Unknown field: ' + fieldName); 218 | } 219 | 220 | const field = this.fields[this.fieldIndexes[fieldName]]; 221 | 222 | return field.typeArray[idx * field.typeElemSize + field.typeOffset]; 223 | } 224 | } 225 | 226 | export class ChunkedRecordArray { 227 | 228 | constructor(fields, chunkSize) { 229 | this.fields = fields; 230 | this.chunkSize = chunkSize; 231 | this.chunks = []; 232 | this.size = 0; 233 | } 234 | 235 | resize(newSize) { 236 | const chunkCount = Math.ceil(newSize / this.chunkSize); 237 | for (let i = this.chunks.length; i < chunkCount; i++) { 238 | this.chunks.push(new PackedRecordArray(this.fields, this.chunkSize)); 239 | } 240 | 241 | this.size = newSize; 242 | } 243 | 244 | getSize() { 245 | return this.size; 246 | } 247 | 248 | setElement(idx, obj) { 249 | if (idx + 1 > this.getSize()) { 250 | this.resize(idx + 1); 251 | } 252 | 253 | this.chunks[Math.floor(idx / this.chunkSize)].setElement(idx % this.chunkSize, obj); 254 | } 255 | 256 | setElementFieldValue(idx, fieldName, fieldValue) { 257 | this.chunks[Math.floor(idx / this.chunkSize)].setElementFieldValue(idx % this.chunkSize, fieldName, fieldValue); 258 | } 259 | 260 | getElement(idx) { 261 | return this.chunks[Math.floor(idx / this.chunkSize)].getElement(idx % this.chunkSize); 262 | } 263 | 264 | getElementFieldValue(idx, fieldName) { 265 | return this.chunks[Math.floor(idx / this.chunkSize)].getElementFieldValue(idx % this.chunkSize, fieldName); 266 | } 267 | } 268 | 269 | 270 | /* 271 | FIXME move all categories related stuff elsewhere (e.g. in a dedicated module) 272 | */ 273 | let categCache = null; 274 | const categStoreKey = 'spx-report-current-categories'; 275 | 276 | export function getCategories(includeUncategorized=false) { 277 | if (categCache === null) { 278 | let loaded = window.localStorage.getItem(categStoreKey); 279 | categCache = !!loaded ? JSON.parse(loaded): []; 280 | categCache.forEach(c => { 281 | c.patterns = c.patterns.map(p => new RegExp(p, 'gi')) 282 | }); 283 | } 284 | 285 | if (includeUncategorized) { 286 | let all = categCache.slice(); 287 | all.push({ 288 | label: '', 289 | color: [140,140,140], 290 | patterns: [/./], 291 | isDefault: true 292 | }); 293 | return all; 294 | } 295 | return categCache; 296 | } 297 | 298 | export function setCategories(categories) { 299 | categCache = null; 300 | categories = categories.filter(c => !c.isDefault) 301 | categories.forEach(c => { 302 | c.patterns = c.patterns.map(p => p.source) 303 | }); 304 | window.localStorage.setItem(categStoreKey, JSON.stringify(categories)); 305 | } 306 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.22.2 AS mailpit 2 | 3 | RUN apk add --no-cache upx 4 | 5 | RUN wget https://github.com/axllent/mailpit/releases/download/v1.27.11/mailpit-linux-amd64.tar.gz -O mailpit.tar.gz 6 | RUN tar --extract --file mailpit.tar.gz 7 | # compress mailpit as it weighs around 24Mb 8 | RUN upx mailpit 9 | 10 | # don't use alpine:edge as it is not refreshed that often 11 | FROM alpine:3.22.2 AS image 12 | LABEL maintainer="8ctopus " 13 | 14 | # expose ports 15 | EXPOSE 80/tcp 16 | EXPOSE 443/tcp 17 | 18 | RUN \ 19 | # update repositories to edge 20 | printf "https://dl-cdn.alpinelinux.org/alpine/edge/main\nhttps://dl-cdn.alpinelinux.org/alpine/edge/community\n" > /etc/apk/repositories && \ 21 | # add testing repository 22 | printf "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing\n" >> /etc/apk/repositories && \ 23 | \ 24 | # update apk repositories 25 | apk update && \ 26 | # upgrade all packages 27 | apk upgrade && \ 28 | \ 29 | apk add --no-cache \ 30 | # add tini https://github.com/krallin/tini/issues/8 31 | tini \ 32 | \ 33 | # install latest certificates for ssl 34 | ca-certificates@testing \ 35 | \ 36 | # install console tools 37 | inotify-tools@testing \ 38 | \ 39 | # install zsh 40 | zsh@testing \ 41 | zsh-vcs@testing \ 42 | \ 43 | # install php 44 | php85@testing \ 45 | # php85-apache2@testing \ 46 | php85-bcmath@testing \ 47 | php85-brotli@testing \ 48 | php85-bz2@testing \ 49 | php85-calendar@testing \ 50 | # php85-cgi@testing \ 51 | php85-common@testing \ 52 | php85-ctype@testing \ 53 | php85-curl@testing \ 54 | # php85-dba@testing \ 55 | # php85-dbg@testing \ 56 | # php85-dev@testing \ 57 | # php85-doc@testing \ 58 | php85-dom@testing \ 59 | # php85-embed@testing \ 60 | # php85-enchant@testing \ 61 | php85-exif@testing \ 62 | # php85-ffi@testing \ 63 | php85-fileinfo@testing \ 64 | php85-ftp@testing \ 65 | php85-gd@testing \ 66 | php85-gettext@testing \ 67 | # php85-gmp@testing \ 68 | php85-json@testing \ 69 | php85-iconv@testing \ 70 | php85-imap@testing \ 71 | php85-intl@testing \ 72 | php85-ldap@testing \ 73 | # php85-litespeed@testing \ 74 | php85-mbstring@testing \ 75 | php85-mysqli@testing \ 76 | # php85-mysqlnd@testing \ 77 | # php85-odbc@testing \ 78 | # php85-opcache@testing \ 79 | php85-openssl@testing \ 80 | php85-pcntl@testing \ 81 | php85-pdo@testing \ 82 | php85-pdo_mysql@testing \ 83 | # php85-pdo_odbc@testing \ 84 | # php85-pdo_pgsql@testing \ 85 | php85-pdo_sqlite@testing \ 86 | # php85-pear@testing \ 87 | # php85-pgsql@testing \ 88 | php85-phar@testing \ 89 | # php85-phpdbg@testing \ 90 | php85-posix@testing \ 91 | # php85-pspell@testing \ 92 | php85-session@testing \ 93 | # php85-shmop@testing \ 94 | php85-simplexml@testing \ 95 | # php85-snmp@testing \ 96 | # php85-soap@testing \ 97 | # php85-sockets@testing \ 98 | php85-sodium@testing \ 99 | php85-sqlite3@testing \ 100 | # php85-sysvmsg@testing \ 101 | # php85-sysvsem@testing \ 102 | # php85-sysvshm@testing \ 103 | # php85-tideways_xhprof@testing \ 104 | # php85-tidy@testing \ 105 | php85-tokenizer@testing \ 106 | php85-xml@testing \ 107 | php85-xmlreader@testing \ 108 | php85-xmlwriter@testing \ 109 | php85-zip@testing \ 110 | \ 111 | # use php85-fpm instead of php85-apache 112 | php85-fpm@testing \ 113 | \ 114 | # i18n 115 | icu-data-full \ 116 | \ 117 | # PECL extensions 118 | # php85-pecl-amqp@testing \ 119 | # php85-pecl-apcu@testing \ 120 | # php85-pecl-ast@testing \ 121 | # php85-pecl-couchbase@testing \ 122 | # php85-pecl-event@testing \ 123 | # php85-pecl-igbinary@testing \ 124 | # php85-pecl-imagick@testing \ 125 | # php85-pecl-imagick-dev@testing \ 126 | # php85-pecl-lzf@testing \ 127 | # php85-pecl-mailparse@testing \ 128 | # php85-pecl-maxminddb@testing \ 129 | # php85-pecl-mcrypt@testing \ 130 | # php85-pecl-memcache@testing \ 131 | # php85-pecl-memcached@testing \ 132 | # php85-pecl-mongodb@testing \ 133 | # php85-pecl-msgpack@testing \ 134 | # php85-pecl-oauth@testing \ 135 | # php85-pecl-protobuf@testing \ 136 | # php85-pecl-psr@testing \ 137 | # php85-pecl-rdkafka@testing \ 138 | # php85-pecl-redis@testing \ 139 | # php85-pecl-ssh2@testing \ 140 | # php85-pecl-timezonedb@testing \ 141 | # php85-pecl-uploadprogress@testing \ 142 | # php85-pecl-uploadprogress-doc@testing \ 143 | # php85-pecl-uuid@testing \ 144 | # php85-pecl-vips@testing \ 145 | php85-pecl-xdebug@testing \ 146 | # php85-pecl-xhprof@testing \ 147 | # php85-pecl-xhprof-assets@testing \ 148 | # php85-pecl-yaml@testing \ 149 | # php85-pecl-zstd@testing \ 150 | # php85-pecl-zstd-dev@testing 151 | \ 152 | # install apache 153 | apache2@testing \ 154 | apache2-ssl@testing \ 155 | apache2-proxy@testing && \ 156 | \ 157 | # fix iconv(): Wrong encoding, conversion from "UTF-8" to "UTF-8//IGNORE" is not allowed 158 | # This error occurs when there's an issue with the iconv library's handling of character encoding conversion, 159 | # specifically when trying to convert from UTF-8 to US-ASCII with TRANSLIT option. 160 | # This is a common issue in Alpine Linux-based PHP images because Alpine uses musl libc which includes a different 161 | # implementation of iconv than the more common GNU libiconv. 162 | apk add --no-cache --repository https://dl-cdn.alpinelinux.org/alpine/v3.13/community/ gnu-libiconv=1.15-r3 && \ 163 | \ 164 | # delete apk cache (needs to be done before the layer is written) 165 | rm -rf /var/cache/apk/* 166 | 167 | ENV LD_PRELOAD=/usr/lib/preloadable_libiconv.so 168 | 169 | RUN \ 170 | # add user www-data 171 | # group www-data already exists 172 | # -H don't create home directory 173 | # -D don't assign a password 174 | # -S create a system user 175 | adduser -H -D -S -G www-data -s /sbin/nologin www-data && \ 176 | \ 177 | # update user and group apache runs under 178 | sed -i 's|User apache|User www-data|g' /etc/apache2/httpd.conf && \ 179 | sed -i 's|Group apache|Group www-data|g' /etc/apache2/httpd.conf && \ 180 | # enable mod rewrite (rewrite urls in htaccess) 181 | sed -i 's|#LoadModule rewrite_module modules/mod_rewrite.so|LoadModule rewrite_module modules/mod_rewrite.so|g' /etc/apache2/httpd.conf && \ 182 | # enable important apache modules 183 | sed -i 's|#LoadModule deflate_module modules/mod_deflate.so|LoadModule deflate_module modules/mod_deflate.so|g' /etc/apache2/httpd.conf && \ 184 | sed -i 's|#LoadModule expires_module modules/mod_expires.so|LoadModule expires_module modules/mod_expires.so|g' /etc/apache2/httpd.conf && \ 185 | sed -i 's|#LoadModule ext_filter_module modules/mod_ext_filter.so|LoadModule ext_filter_module modules/mod_ext_filter.so|g' /etc/apache2/httpd.conf && \ 186 | # switch from mpm_prefork to mpm_event 187 | sed -i 's|LoadModule mpm_prefork_module modules/mod_mpm_prefork.so|#LoadModule mpm_prefork_module modules/mod_mpm_prefork.so|g' /etc/apache2/httpd.conf && \ 188 | sed -i 's|#LoadModule mpm_event_module modules/mod_mpm_event.so|LoadModule mpm_event_module modules/mod_mpm_event.so|g' /etc/apache2/httpd.conf && \ 189 | # authorize all directives in .htaccess 190 | sed -i 's| AllowOverride None| AllowOverride All|g' /etc/apache2/httpd.conf && \ 191 | # authorize all changes from htaccess 192 | sed -i 's|Options Indexes FollowSymLinks|Options All|g' /etc/apache2/httpd.conf && \ 193 | # configure php-fpm to run as www-data 194 | sed -i 's|user = nobody|user = www-data|g' /etc/php85/php-fpm.d/www.conf && \ 195 | sed -i 's|group = nobody|group = www-data|g' /etc/php85/php-fpm.d/www.conf && \ 196 | sed -i 's|;listen.owner = nobody|listen.owner = www-data|g' /etc/php85/php-fpm.d/www.conf && \ 197 | sed -i 's|;listen.group = group|listen.group = www-data|g' /etc/php85/php-fpm.d/www.conf && \ 198 | # configure php-fpm to use unix socket 199 | sed -i 's|listen = 127.0.0.1:9000|listen = /var/run/php-fpm8.sock|g' /etc/php85/php-fpm.d/www.conf && \ 200 | # update apache timeout for easier debugging 201 | sed -i 's|^Timeout .*$|Timeout 600|g' /etc/apache2/conf.d/default.conf && \ 202 | # add vhosts to apache 203 | echo -e "\n# Include the virtual host configurations:\nIncludeOptional /sites/config/vhosts/*.conf" >> /etc/apache2/httpd.conf && \ 204 | # set localhost server name 205 | sed -i "s|#ServerName .*:80|ServerName localhost:80|g" /etc/apache2/httpd.conf && \ 206 | # update php max execution time for easier debugging 207 | sed -i 's|^max_execution_time .*$|max_execution_time = 600|g' /etc/php85/php.ini && \ 208 | # update max upload size 209 | sed -i 's|^upload_max_filesize = 2M$|upload_max_filesize = 20M|g' /etc/php85/php.ini && \ 210 | # php log everything 211 | sed -i 's|^error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT$|error_reporting = E_ALL|g' /etc/php85/php.ini 212 | 213 | COPY --chown=root:root include /tmp 214 | 215 | RUN \ 216 | # create php aliases 217 | ln -s /usr/bin/php85 /usr/bin/php && \ 218 | ln -s /usr/sbin/php-fpm85 /usr/sbin/php-fpm && \ 219 | \ 220 | # configure zsh 221 | mv /tmp/zshrc /etc/zsh/zshrc && \ 222 | # configure xdebug 223 | mv /tmp/xdebug.ini /etc/php85/conf.d/xdebug.ini && \ 224 | \ 225 | # install composer 226 | chmod +x /tmp/composer.sh && \ 227 | /tmp/composer.sh && \ 228 | mv /composer.phar /usr/bin/composer && \ 229 | \ 230 | # install self-signed certificate generator 231 | chmod +x /tmp/selfsign.sh && \ 232 | /tmp/selfsign.sh && \ 233 | mv /selfsign.phar /usr/bin/selfsign && \ 234 | chmod +x /usr/bin/selfsign && \ 235 | \ 236 | # add php-spx - /usr/share/misc/php-spx/assets/web-ui 237 | mv /tmp/php-spx/spx.ini /etc/php85/conf.d/spx.ini && \ 238 | mv /tmp/php-spx/spx.so /usr/lib/php85/modules/spx.so && \ 239 | mkdir -p /usr/share/misc/php-spx/ && \ 240 | mv /tmp/php-spx/assets /usr/share/misc/php-spx/ && \ 241 | \ 242 | # add default sites 243 | mv /tmp/sites/ /sites.bak/ && \ 244 | # add entry point script 245 | #mv /tmp/start.sh /tmp/start.sh 246 | # make entry point script executable 247 | chmod +x /tmp/start.sh && \ 248 | # set working dir 249 | mkdir /sites/ && \ 250 | chown www-data:www-data /sites/ && \ 251 | mkdir -p /sites/localhost/logs/ && \ 252 | chown -R www-data:www-data /sites/localhost/logs 253 | 254 | # add mailpit (intercept emails) 255 | COPY --chown=root:root --from=mailpit /mailpit /usr/local/bin/mailpit 256 | RUN chmod +x /usr/local/bin/mailpit && \ 257 | ln -sf /usr/local/bin/mailpit /usr/sbin/sendmail 258 | 259 | WORKDIR /sites/ 260 | 261 | # set entrypoint 262 | ENTRYPOINT ["tini", "-vw"] 263 | 264 | # run script 265 | CMD ["/tmp/start.sh"] 266 | -------------------------------------------------------------------------------- /Dockerfile-from-src: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18.4 AS build 2 | 3 | # update apk repositories 4 | RUN apk update 5 | 6 | # upgrade all 7 | RUN apk upgrade 8 | 9 | # add c build tools 10 | RUN apk add build-base 11 | 12 | # add git 13 | RUN apk add git 14 | 15 | # add development packages 16 | RUN apk add \ 17 | argon2-dev \ 18 | autoconf \ 19 | bison \ 20 | bzip2-dev \ 21 | curl-dev \ 22 | freetype-dev \ 23 | gettext-dev \ 24 | git \ 25 | icu-dev \ 26 | imap-dev \ 27 | krb5-dev \ 28 | libavif-dev \ 29 | libffi-dev \ 30 | libjpeg-turbo-dev \ 31 | libpng-dev \ 32 | #db-dev \ 33 | libsodium-dev \ 34 | libxml2-dev \ 35 | libwebp-dev \ 36 | libxpm-dev \ 37 | libzip-dev \ 38 | oniguruma-dev \ 39 | openldap-dev \ 40 | pcre2-dev \ 41 | pkgconfig \ 42 | re2c \ 43 | sqlite-dev 44 | 45 | # build php from src 46 | WORKDIR /php-src 47 | RUN git clone --depth 200 https://github.com/php/php-src.git /php-src 48 | 49 | # shallow clones do not have tags, so fetch the tag we need 50 | RUN git fetch --depth 1 origin tag php-8.3.0RC6 51 | RUN git checkout php-8.3.0RC6 52 | 53 | # FIX ME why --force? 54 | RUN ./buildconf --force 55 | 56 | # configuration can be extracted from alpine using command `php-fpm82 -i` 57 | # build as shared module = not included inside the php binary 58 | RUN ./configure \ 59 | --disable-gd-jis-conv \ 60 | --disable-rpath \ 61 | --disable-short-tags \ 62 | 63 | --datadir=/usr/share/php83 \ 64 | --libdir=/usr/lib/php83 \ 65 | --localstatedir=/var \ 66 | --prefix=/usr \ 67 | --program-suffix=83 \ 68 | --sysconfdir=/etc/php83 \ 69 | --with-config-file-path=/etc/php83 \ 70 | --with-config-file-scan-dir=/etc/php83/conf.d \ 71 | 72 | #--host=x86_64-alpine-linux-musl \ 73 | #--build=x86_64-alpine-linux-musl \ 74 | 75 | --enable-fpm \ 76 | 77 | --enable-bcmath=shared \ 78 | --enable-calendar=shared \ 79 | --enable-ctype=shared \ 80 | #--enable-dba=shared \ 81 | --enable-dom=shared \ 82 | --enable-embed \ 83 | --enable-exif=shared \ 84 | --enable-fileinfo=shared \ 85 | --enable-ftp=shared \ 86 | --enable-gd=shared \ 87 | --enable-intl=shared \ 88 | --enable-litespeed \ 89 | --enable-mbstring=shared \ 90 | --enable-mysqlnd=shared \ 91 | --enable-opcache=shared \ 92 | --enable-pcntl=shared \ 93 | --enable-pdo=shared \ 94 | --enable-phar=shared \ 95 | --enable-phpdbg \ 96 | --enable-posix=shared \ 97 | --enable-session=shared \ 98 | --enable-shmop=shared \ 99 | --enable-simplexml=shared \ 100 | --enable-soap=shared \ 101 | --enable-sockets=shared \ 102 | --enable-sysvmsg=shared \ 103 | --enable-sysvsem=shared \ 104 | --enable-sysvshm=shared \ 105 | --enable-tokenizer=shared \ 106 | --enable-xml=shared \ 107 | --enable-xmlreader=shared \ 108 | --enable-xmlwriter=shared \ 109 | 110 | --with-avif \ 111 | --with-bz2=shared \ 112 | --with-curl=shared \ 113 | #--with-dbmaker=shared \ 114 | #--with-enchant=shared \ 115 | --with-external-pcre \ 116 | --with-ffi=shared \ 117 | --with-fpm-acl \ 118 | --with-freetype \ 119 | #--with-gdbm \ 120 | --with-gettext=shared \ 121 | #--with-gmp=shared \ 122 | --with-iconv=shared \ 123 | --with-imap=shared \ 124 | --with-imap-ssl \ 125 | --with-jpeg \ 126 | --with-kerberos \ 127 | --with-layout=GNU \ 128 | --with-ldap-sasl \ 129 | --with-ldap=shared \ 130 | --with-libedit \ 131 | --with-libxml \ 132 | #--with-lmdb \ 133 | --with-mysql-sock=/run/mysqld/mysqld.sock \ 134 | --with-mysqli=shared \ 135 | --with-openssl=shared \ 136 | --with-password-argon2 \ 137 | #--with-pdo-dblib=shared,/usr \ 138 | --with-pdo-mysql=shared,mysqlnd \ 139 | #--with-pdo-odbc=shared,unixODBC,/usr \ 140 | #--with-pdo-pgsql=shared \ 141 | --with-pdo-sqlite=shared \ 142 | --with-pear=/usr/share/php83 \ 143 | #--with-pgsql=shared \ 144 | --with-pic \ 145 | #--with-pspell=shared \ 146 | #--with-snmp=shared \ 147 | --with-sodium=shared \ 148 | --with-sqlite3=shared \ 149 | --with-system-ciphers \ 150 | --with-tidy=shared \ 151 | #--with-unixODBC=shared,/usr \ 152 | --with-webp \ 153 | --with-xpm \ 154 | --with-xsl=shared \ 155 | --with-zip=shared \ 156 | --with-zlib \ 157 | 158 | --without-readline 159 | 160 | RUN make -j $(nproc) 161 | RUN make install 162 | 163 | # build xdebug 164 | WORKDIR /xdebug 165 | RUN apk add linux-headers 166 | 167 | RUN git clone --depth 200 https://github.com/xdebug/xdebug.git /xdebug 168 | RUN git checkout 3.3.0alpha3 169 | 170 | RUN phpize 171 | RUN ./configure --enable-xdebug 172 | RUN make 173 | RUN make install 174 | 175 | # build php-spx 176 | WORKDIR /php-spx 177 | RUN git clone --depth 100 https://github.com/NoiseByNorthwest/php-spx.git /php-spx 178 | RUN git checkout master 179 | 180 | # build php-spx 181 | RUN phpize 182 | RUN ./configure 183 | RUN make 184 | RUN make install 185 | 186 | # start again with a new image 187 | FROM alpine:3.18.4 188 | LABEL maintainer="8ctopus " 189 | 190 | # copy php files 191 | COPY --from=build /usr/local/bin/php /usr/local/bin/php 192 | COPY --from=build /usr/local/sbin/php-fpm /usr/local/sbin/php-fpm 193 | 194 | COPY --from=build /php-src/php.ini-development /etc/php83/php.ini 195 | COPY --from=build /php-src/sapi/fpm/php-fpm.conf /etc/php83/php-fpm.conf 196 | COPY --from=build /php-src/sapi/fpm/www.conf /etc/php83/php-fpm.d/www.conf 197 | 198 | COPY --chown=root:root include/extensions.ini /etc/php83/conf.d/extensions.ini 199 | COPY --from=build /usr/local/lib/php/extensions/no-debug-non-zts-20230831/*.so /usr/local/lib/php/extensions/ 200 | 201 | # expose ports 202 | EXPOSE 80/tcp 203 | EXPOSE 443/tcp 204 | 205 | # update apk repositories 206 | RUN apk update 207 | 208 | # upgrade all 209 | RUN apk upgrade 210 | 211 | # add tini https://github.com/krallin/tini/issues/8 212 | RUN apk add tini 213 | 214 | # install latest certificates for ssl 215 | RUN apk add ca-certificates 216 | 217 | # install console tools 218 | RUN apk add \ 219 | inotify-tools 220 | 221 | # install zsh 222 | RUN apk add \ 223 | zsh \ 224 | zsh-vcs 225 | 226 | # configure zsh 227 | COPY --chown=root:root include/zshrc /etc/zsh/zshrc 228 | 229 | # add i18n 230 | RUN apk add \ 231 | icu-data-full 232 | 233 | # configure extensions load path 234 | RUN sed -i 's|;extension_dir = "./"|extension_dir = "/usr/local/lib/php/extensions/"|g' /etc/php83/php.ini 235 | 236 | # configure php-fpm to run as www-data 237 | RUN sed -i 's|user = nobody|user = www-data|g' /etc/php83/php-fpm.d/www.conf 238 | RUN sed -i 's|group = nobody|group = www-data|g' /etc/php83/php-fpm.d/www.conf 239 | RUN sed -i 's|;listen.owner = nobody|listen.owner = www-data|g' /etc/php83/php-fpm.d/www.conf 240 | RUN sed -i 's|;listen.group = group|listen.group = www-data|g' /etc/php83/php-fpm.d/www.conf 241 | 242 | # configure php-fpm to use unix socket 243 | RUN sed -i 's|listen = 127.0.0.1:9000|listen = /var/run/php-fpm8.sock|g' /etc/php83/php-fpm.d/www.conf 244 | 245 | # update php max execution time for easier debugging 246 | RUN sed -i 's|^max_execution_time .*$|max_execution_time = 600|g' /etc/php83/php.ini 247 | 248 | # php log everything 249 | RUN sed -i 's|^error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT$|error_reporting = E_ALL|g' /etc/php83/php.ini 250 | 251 | # add xdebug extension 252 | COPY --chown=root:root include/xdebug.ini /etc/php83/conf.d/xdebug.ini 253 | 254 | # add spx extension 255 | COPY --chown=root:root include/php-spx/assets/ /usr/share/misc/php-spx/assets/ 256 | COPY --chown=root:root include/php-spx/spx.ini /etc/php83/conf.d/spx.ini 257 | 258 | RUN php -i 259 | 260 | # php requires these libraries 261 | #RUN apk add \ 262 | # gettext-libs \ 263 | # icu \ 264 | # libbz2 \ 265 | # libcurl \ 266 | # libintl \ 267 | # libpng \ 268 | # libsodium \ 269 | # libxml2 \ 270 | # libzip \ 271 | # oniguruma \ 272 | # openldap \ 273 | # sqlite-libs 274 | 275 | # install composer (currently installs php8.1 which creates a mess, use script approach instead to install) 276 | #RUN apk add composer 277 | 278 | # add composer script 279 | COPY --chown=root:root include/composer.sh /tmp/composer.sh 280 | 281 | # make composer script executable 282 | RUN chmod +x /tmp/composer.sh 283 | 284 | # install composer 285 | RUN /tmp/composer.sh 286 | 287 | # move composer binary to usr bin 288 | RUN mv /composer.phar /usr/bin/composer 289 | 290 | # install apache 291 | RUN apk add \ 292 | apache2 \ 293 | apache2-ssl \ 294 | apache2-proxy 295 | 296 | # delete apk cache 297 | RUN rm -rf /var/cache/apk/* 298 | 299 | # add user www-data 300 | # group www-data already exists 301 | # -H don't create home directory 302 | # -D don't assign a password 303 | # -S create a system user 304 | RUN adduser -H -D -S -G www-data -s /sbin/nologin www-data 305 | 306 | # update user and group apache runs under 307 | RUN sed -i 's|User apache|User www-data|g' /etc/apache2/httpd.conf 308 | RUN sed -i 's|Group apache|Group www-data|g' /etc/apache2/httpd.conf 309 | 310 | # enable url rewrite module 311 | RUN sed -i 's|#LoadModule rewrite_module modules/mod_rewrite.so|LoadModule rewrite_module modules/mod_rewrite.so|g' /etc/apache2/httpd.conf 312 | 313 | # enable important apache modules 314 | RUN sed -i 's|#LoadModule deflate_module modules/mod_deflate.so|LoadModule deflate_module modules/mod_deflate.so|g' /etc/apache2/httpd.conf 315 | RUN sed -i 's|#LoadModule expires_module modules/mod_expires.so|LoadModule expires_module modules/mod_expires.so|g' /etc/apache2/httpd.conf 316 | RUN sed -i 's|#LoadModule ext_filter_module modules/mod_ext_filter.so|LoadModule ext_filter_module modules/mod_ext_filter.so|g' /etc/apache2/httpd.conf 317 | 318 | # switch from mpm_prefork to mpm_event 319 | RUN sed -i 's|LoadModule mpm_prefork_module modules/mod_mpm_prefork.so|#LoadModule mpm_prefork_module modules/mod_mpm_prefork.so|g' /etc/apache2/httpd.conf 320 | RUN sed -i 's|#LoadModule mpm_event_module modules/mod_mpm_event.so|LoadModule mpm_event_module modules/mod_mpm_event.so|g' /etc/apache2/httpd.conf 321 | 322 | # authorize all directives in htaccess 323 | RUN sed -i 's| AllowOverride None| AllowOverride All|g' /etc/apache2/httpd.conf 324 | 325 | # authorize all changes from htaccess 326 | RUN sed -i 's|Options Indexes FollowSymLinks|Options All|g' /etc/apache2/httpd.conf 327 | 328 | # update apache timeout for easier debugging 329 | RUN sed -i 's|^Timeout .*$|Timeout 600|g' /etc/apache2/conf.d/default.conf 330 | 331 | # add vhosts to apache 332 | RUN echo -e "\n# Include the virtual host configurations:\nIncludeOptional /sites/config/vhosts/*.conf" >> /etc/apache2/httpd.conf 333 | 334 | # set localhost server name 335 | RUN sed -i "s|#ServerName .*:80|ServerName localhost:80|g" /etc/apache2/httpd.conf 336 | 337 | # add default sites 338 | COPY --chown=www-data:www-data include/sites/ /sites.bak/ 339 | 340 | # add entry point script 341 | COPY --chown=root:root include/start.sh /tmp/start.sh 342 | 343 | # make entry point script executable 344 | RUN chmod +x /tmp/start.sh 345 | 346 | # set working dir 347 | RUN mkdir /sites/ 348 | RUN chown www-data:www-data /sites/ 349 | WORKDIR /sites/ 350 | 351 | # install self-signed certificate generator 352 | COPY --chown=root:root include/selfsign.sh /tmp/selfsign.sh 353 | RUN chmod +x /tmp/selfsign.sh 354 | RUN /tmp/selfsign.sh 355 | RUN mv /selfsign.phar /usr/bin/selfsign 356 | RUN chmod +x /usr/bin/selfsign 357 | 358 | # set entrypoint 359 | ENTRYPOINT ["tini", "-vw"] 360 | 361 | # run script 362 | CMD ["/tmp/start.sh"] 363 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SPX Report 6 | 7 | 8 | 9 |
10 |
11 |

Initializing...

12 |

13 |
14 |
15 |
16 |

17 |
18 |
19 | 20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
39 |
40 |
41 |
48 |
49 |
50 |
51 |
52 | 53 | 58 | 59 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 301 | 302 | 303 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SPX Control Panel 6 | 18 | 19 | 20 | 21 |

SPX Control Panel

22 | 23 |
24 |
25 | Configuration 26 |
27 | 28 | 29 |
30 |
31 |
32 | 33 |
34 | 35 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 286 | 287 | 288 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/js/profileData.js: -------------------------------------------------------------------------------- 1 | /* SPX - A simple profiler for PHP 2 | * Copyright (C) 2017-2020 Sylvain Lassaut 3 | * 4 | * This program is free software: you can redistribute it and/or modify 5 | * it under the terms of the GNU General Public License as published by 6 | * the Free Software Foundation; either version 3 of the License, or 7 | * (at your option) any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | 19 | import * as utils from './?SPX_UI_URI=/js/utils.js'; 20 | import * as fmt from './?SPX_UI_URI=/js/fmt.js'; 21 | import * as math from './?SPX_UI_URI=/js/math.js'; 22 | 23 | class MetricValueSet { 24 | 25 | static createFromMetricsAndValue(metrics, value) { 26 | let values = {}; 27 | for (let m of metrics) { 28 | values[m] = value; 29 | } 30 | 31 | return new MetricValueSet(values); 32 | } 33 | 34 | static lerpByTime(a, b, time) { 35 | if (a.values['wt'] == b.values['wt']) { 36 | return a.copy(); 37 | } 38 | 39 | const dist = (time - a.values['wt']) / (b.values['wt'] - a.values['wt']); 40 | 41 | let values = {}; 42 | for (let m in a.values) { 43 | values[m] = math.lerp( 44 | a.values[m], 45 | b.values[m], 46 | dist 47 | ); 48 | } 49 | 50 | return new MetricValueSet(values); 51 | } 52 | 53 | constructor(values) { 54 | this.values = values; 55 | } 56 | 57 | copy() { 58 | let copy = {}; 59 | for (let i in this.values) { 60 | copy[i] = this.values[i]; 61 | } 62 | 63 | return new MetricValueSet(copy); 64 | } 65 | 66 | getMetrics() { 67 | return Object.keys(this.values); 68 | } 69 | 70 | getValue(metric) { 71 | return this.values[metric]; 72 | } 73 | 74 | setValue(metric, value) { 75 | this.values[metric] = value; 76 | } 77 | 78 | set(value) { 79 | for (let i in this.values) { 80 | this.values[i] = value; 81 | } 82 | 83 | return this; 84 | } 85 | 86 | add(other) { 87 | for (let i in this.values) { 88 | this.values[i] += other.values[i]; 89 | } 90 | 91 | return this; 92 | } 93 | 94 | sub(other) { 95 | for (let i in this.values) { 96 | this.values[i] -= other.values[i]; 97 | } 98 | 99 | return this; 100 | } 101 | 102 | addPos(other) { 103 | for (let i in this.values) { 104 | if (other.values[i] > 0) { 105 | this.values[i] += other.values[i]; 106 | } 107 | } 108 | 109 | return this; 110 | } 111 | 112 | addNeg(other) { 113 | for (let i in this.values) { 114 | if (other.values[i] < 0) { 115 | this.values[i] += other.values[i]; 116 | } 117 | } 118 | 119 | return this; 120 | } 121 | 122 | min(other) { 123 | for (let i in this.values) { 124 | this.values[i] = Math.min(this.values[i], other.values[i]); 125 | } 126 | 127 | return this; 128 | } 129 | 130 | max(other) { 131 | for (let i in this.values) { 132 | this.values[i] = Math.max(this.values[i], other.values[i]); 133 | } 134 | 135 | return this; 136 | } 137 | } 138 | 139 | class CallListEntry { 140 | 141 | constructor(list, idx) { 142 | if (idx < 0 || idx >= list.getSize()) { 143 | throw new Error('Out of bound index: ' + idx); 144 | } 145 | 146 | this.list = list; 147 | this.idx = idx; 148 | this.elemOffset = idx * this.list.elemSize; 149 | } 150 | 151 | getIdx() { 152 | return this.idx; 153 | } 154 | 155 | getFunctionIdx() { 156 | return this.list.array.getElementFieldValue(this.idx, 'functionIdx'); 157 | } 158 | 159 | getFunctionName() { 160 | return this.list.functionNames[this.getFunctionIdx()]; 161 | } 162 | 163 | getMetrics() { 164 | return this.list.metrics; 165 | } 166 | 167 | getMetricValue(type, metric) { 168 | return this.list.array.getElementFieldValue(this.idx, type + '_' + metric); 169 | } 170 | 171 | getMetricValues(type) { 172 | let values = {}; 173 | for (let metric of this.list.metrics) { 174 | values[metric] = this.getMetricValue(type, metric); 175 | } 176 | 177 | return new MetricValueSet(values); 178 | } 179 | 180 | getStartMetricValues() { 181 | return this.getMetricValues('start'); 182 | } 183 | 184 | getEndMetricValues() { 185 | return this.getMetricValues('end'); 186 | } 187 | 188 | getIncMetricValues() { 189 | return this.getEndMetricValues().copy().sub(this.getStartMetricValues()); 190 | } 191 | 192 | getExcMetricValues() { 193 | return this.getMetricValues('exc'); 194 | } 195 | 196 | getStart(metric) { 197 | return this.getMetricValue('start', metric); 198 | } 199 | 200 | getEnd(metric) { 201 | return this.getMetricValue('end', metric); 202 | } 203 | 204 | getInc(metric) { 205 | return this.getEnd(metric) - this.getStart(metric); 206 | } 207 | 208 | getExc(metric) { 209 | return this.getMetricValue('exc', metric); 210 | } 211 | 212 | getTimeRange() { 213 | return new math.Range(this.getStart('wt'), this.getEnd('wt')); 214 | } 215 | 216 | getParent() { 217 | const parentIdx = this.list.array.getElementFieldValue(this.idx, 'parentIdx'); 218 | if (parentIdx < 0) { 219 | return null; 220 | } 221 | 222 | return this.list.getCall(parentIdx); 223 | } 224 | 225 | getAncestors() { 226 | const ancestors = []; 227 | 228 | let parent = this.getParent(); 229 | while (parent != null) { 230 | ancestors.push(parent); 231 | parent = parent.getParent(); 232 | } 233 | 234 | return ancestors; 235 | } 236 | 237 | getStack() { 238 | const stack = this.getAncestors().reverse(); 239 | stack.push(this); 240 | 241 | return stack; 242 | } 243 | 244 | getDepth() { 245 | let parentIdx = this.list.array.getElementFieldValue(this.idx, 'parentIdx'); 246 | let depth = 0; 247 | while (parentIdx >= 0) { 248 | parentIdx = this.list.array.getElementFieldValue(parentIdx, 'parentIdx'); 249 | depth++; 250 | } 251 | 252 | return depth; 253 | } 254 | 255 | getCycleDepth() { 256 | const functionIdx = this.getFunctionIdx(); 257 | let parentIdx = this.list.array.getElementFieldValue(this.idx, 'parentIdx'); 258 | 259 | let cycleDepth = 0; 260 | while (parentIdx >= 0) { 261 | const parentFunctionIdx = this.list.array.getElementFieldValue(parentIdx, 'functionIdx'); 262 | if (functionIdx == parentFunctionIdx) { 263 | cycleDepth++; 264 | } 265 | 266 | parentIdx = this.list.array.getElementFieldValue(parentIdx, 'parentIdx'); 267 | } 268 | 269 | return cycleDepth; 270 | } 271 | } 272 | 273 | class TruncatedCallListEntry extends CallListEntry { 274 | 275 | constructor(call, lowerBound, upperBound) { 276 | super(call.list, call.idx); 277 | 278 | this.customMetricValues = {}; 279 | 280 | let truncated = false; 281 | 282 | if (lowerBound && lowerBound.getValue('wt') > this.getStart('wt')) { 283 | truncated = true; 284 | this.customMetricValues['start'] = lowerBound; 285 | } 286 | 287 | if (upperBound && upperBound.getValue('wt') < this.getEnd('wt')) { 288 | truncated = true; 289 | this.customMetricValues['end'] = upperBound; 290 | } 291 | 292 | if (truncated) { 293 | this.customMetricValues['exc'] = MetricValueSet.createFromMetricsAndValue( 294 | this.getMetrics(), 295 | 0 296 | ); 297 | } 298 | } 299 | 300 | getMetricValue(type, metric) { 301 | if (type in this.customMetricValues) { 302 | return this.customMetricValues[type].getValue(metric); 303 | } 304 | 305 | return super.getMetricValue(type, metric); 306 | } 307 | } 308 | 309 | class CallList { 310 | 311 | constructor(functionCount, metrics) { 312 | this.metrics = metrics; 313 | this.functionNames = Array(functionCount).fill("n/a"); 314 | 315 | this.metricOffsets = {}; 316 | for (let i = 0; i < this.metrics.length; i++) { 317 | this.metricOffsets[this.metrics[i]] = i; 318 | } 319 | 320 | const structure = { 321 | functionIdx: 'int32', 322 | parentIdx: 'int32', 323 | }; 324 | 325 | // FIXME use float32 to save space ? 326 | // FIXME or add/compute some stats somewhere to find the best type (e.g. compiled 327 | // file count metric could be stored as uint16) 328 | for (let metric of this.metrics) { 329 | structure['start_' + metric] = 'float64'; 330 | structure['end_' + metric] = 'float64'; 331 | structure['exc_' + metric] = 'float64'; 332 | } 333 | 334 | this.array = new utils.ChunkedRecordArray(structure, 1024 * 1024); 335 | } 336 | 337 | getSize() { 338 | return this.array.size; 339 | } 340 | 341 | getMetrics() { 342 | return this.metrics; 343 | } 344 | 345 | setRawCallData(idx, functionNameIdx, parentIdx, start, end, exc) { 346 | const elt = { 347 | functionIdx: functionNameIdx, 348 | parentIdx: parentIdx, 349 | }; 350 | 351 | for (let i = 0; i < this.metrics.length; i++) { 352 | const metric = this.metrics[i]; 353 | 354 | elt['start_' + metric] = start[i]; 355 | elt['end_' + metric] = end[i]; 356 | elt['exc_' + metric] = exc[i]; 357 | } 358 | 359 | this.array.setElement(idx, elt); 360 | 361 | return this; 362 | } 363 | 364 | getCall(idx) { 365 | return new CallListEntry(this, idx); 366 | } 367 | 368 | setFunctionName(idx, functionName) { 369 | this.functionNames[idx] = functionName; 370 | 371 | return this; 372 | } 373 | } 374 | 375 | class CumCostStats { 376 | 377 | constructor(metrics) { 378 | this.min = MetricValueSet.createFromMetricsAndValue(metrics, 0); 379 | this.max = MetricValueSet.createFromMetricsAndValue(metrics, 0); 380 | } 381 | 382 | merge(other) { 383 | this.min.addNeg(other.min); 384 | this.max.addPos(other.max); 385 | } 386 | 387 | mergeMetricValues(metricValues) { 388 | this.min.addNeg(metricValues); 389 | this.max.addPos(metricValues); 390 | } 391 | 392 | getMin(metric) { 393 | return this.min.getValue(metric); 394 | } 395 | 396 | getMax(metric) { 397 | return this.max.getValue(metric); 398 | } 399 | 400 | getRange(metric) { 401 | return new math.Range( 402 | this.getMin(metric), 403 | this.getMax(metric) 404 | ); 405 | } 406 | 407 | getPosRange(metric) { 408 | return new math.Range( 409 | Math.max(0, this.getMin(metric)), 410 | Math.max(0, this.getMax(metric)) 411 | ); 412 | } 413 | 414 | getNegRange(metric) { 415 | return new math.Range( 416 | Math.min(0, this.getMin(metric)), 417 | Math.min(0, this.getMax(metric)) 418 | ); 419 | } 420 | } 421 | 422 | // fixme rename MetricValueSet -> Sample & MetricValuesList -> SampleList ? 423 | // keep in mind that Sample might be to concrete since MetricValueSet can also represent a cost 424 | class MetricValuesList { 425 | 426 | constructor(metrics) { 427 | this.metrics = metrics; 428 | 429 | const structure = {}; 430 | for (let metric of this.metrics) { 431 | structure[metric] = 'float64'; 432 | } 433 | 434 | this.array = new utils.ChunkedRecordArray(structure, 1024 * 1024); 435 | } 436 | 437 | setRawMetricValuesData(idx, rawMetricValuesData) { 438 | const elt = {}; 439 | 440 | for (let i = 0; i < this.metrics.length; i++) { 441 | const metric = this.metrics[i]; 442 | 443 | elt[metric] = rawMetricValuesData[i]; 444 | } 445 | 446 | this.array.setElement(idx, elt); 447 | } 448 | 449 | getCumCostStats(range) { 450 | if (range.length() == 0) { 451 | return new CumCostStats(this.metrics); 452 | } 453 | 454 | let firstIdx = this._findNearestIdx(range.begin); 455 | if (firstIdx < this.array.getSize() - 1 && this.array.getElement(firstIdx)['wt'] < range.begin) { 456 | firstIdx++; 457 | } 458 | 459 | let lastIdx = this._findNearestIdx(range.end); 460 | if (lastIdx > 0 && this.array.getElement(lastIdx)['wt'] > range.end) { 461 | lastIdx--; 462 | } 463 | 464 | const first = this.getMetricValues(range.begin); 465 | const last = this.getMetricValues(range.end); 466 | 467 | let previous = first; 468 | const cumCost = new CumCostStats(previous.getMetrics()); 469 | for (let i = firstIdx; i <= lastIdx; i++) { 470 | const current = new MetricValueSet(this.array.getElement(i)); 471 | cumCost.mergeMetricValues(current.copy().sub(previous)); 472 | previous = current; 473 | } 474 | 475 | cumCost.mergeMetricValues(last.copy().sub(previous)); 476 | 477 | return cumCost; 478 | } 479 | 480 | getMetricValues(time) { 481 | const nearestIdx = this._findNearestIdx(time); 482 | const nearestRawMetricValues = this.array.getElement(nearestIdx); 483 | 484 | if (nearestRawMetricValues['wt'] == time) { 485 | return new MetricValueSet(nearestRawMetricValues); 486 | } 487 | 488 | let lowerRawMetricValues = null; 489 | let upperRawMetricValues = null; 490 | 491 | if (nearestRawMetricValues['wt'] < time) { 492 | lowerRawMetricValues = nearestRawMetricValues; 493 | upperRawMetricValues = this.array.getElement(nearestIdx + 1); 494 | } else { 495 | lowerRawMetricValues = this.array.getElement(nearestIdx - 1); 496 | upperRawMetricValues = nearestRawMetricValues; 497 | } 498 | 499 | return MetricValueSet.lerpByTime( 500 | new MetricValueSet(lowerRawMetricValues), 501 | new MetricValueSet(upperRawMetricValues), 502 | time 503 | ); 504 | } 505 | 506 | _findNearestIdx(time, range) { 507 | range = range || new math.Range(0, this.array.getSize()); 508 | 509 | if (range.length() == 1) { 510 | return range.begin; 511 | } 512 | 513 | const center = Math.floor(range.center()); 514 | const centerTime = this.array.getElementFieldValue(center, 'wt'); 515 | 516 | if (time < centerTime) { 517 | return this._findNearestIdx(time, new math.Range(range.begin, center)); 518 | } 519 | 520 | if (time > centerTime) { 521 | return this._findNearestIdx(time, new math.Range(center, range.end)); 522 | } 523 | 524 | return center; 525 | } 526 | } 527 | 528 | /* 529 | FIXME remove and do a dead code removal pass 530 | */ 531 | class Stats { 532 | 533 | constructor(metrics) { 534 | this.min = MetricValueSet.createFromMetricsAndValue(metrics, Number.MAX_VALUE); 535 | this.max = MetricValueSet.createFromMetricsAndValue(metrics, -Number.MAX_VALUE); 536 | this.callMin = MetricValueSet.createFromMetricsAndValue(metrics, Number.MAX_VALUE); 537 | this.callMax = MetricValueSet.createFromMetricsAndValue(metrics, -Number.MAX_VALUE); 538 | } 539 | 540 | getMin(metric) { 541 | return this.min.getValue(metric); 542 | } 543 | 544 | getMax(metric) { 545 | return this.max.getValue(metric); 546 | } 547 | 548 | getRange(metric) { 549 | return new math.Range( 550 | this.getMin(metric), 551 | this.getMax(metric) 552 | ); 553 | } 554 | 555 | getCallMin(metric) { 556 | return this.callMin.getValue(metric); 557 | } 558 | 559 | getCallMax(metric) { 560 | return this.callMax.getValue(metric); 561 | } 562 | 563 | getCallRange(metric) { 564 | return new math.Range( 565 | this.getCallMin(metric), 566 | this.getCallMax(metric) 567 | ); 568 | } 569 | 570 | merge(other) { 571 | this.min.min(other.min); 572 | this.max.max(other.max); 573 | this.callMin.min(other.callMin); 574 | this.callMax.max(other.callMax); 575 | 576 | return this; 577 | } 578 | 579 | mergeMetricValue(metric, value) { 580 | this.min.setValue(metric, Math.min( 581 | this.min.getValue(metric), 582 | value 583 | )); 584 | 585 | this.max.setValue(metric, Math.max( 586 | this.max.getValue(metric), 587 | value 588 | )); 589 | } 590 | 591 | mergeCallMetricValue(metric, value) { 592 | this.callMin.setValue(metric, Math.min( 593 | this.callMin.getValue(metric), 594 | value 595 | )); 596 | 597 | this.callMax.setValue(metric, Math.max( 598 | this.callMax.getValue(metric), 599 | value 600 | )); 601 | } 602 | } 603 | 604 | class FunctionsStats { 605 | 606 | constructor(calls) { 607 | this.functionsStats = new Map(); 608 | 609 | calls = calls || []; 610 | for (let call of calls) { 611 | let stats = this.functionsStats.get(call.getFunctionIdx()); 612 | if (!stats) { 613 | stats = { 614 | functionName: call.getFunctionName(), 615 | maxCycleDepth: 0, 616 | called: 0, 617 | inc: MetricValueSet.createFromMetricsAndValue(call.getMetrics(), 0), 618 | exc: MetricValueSet.createFromMetricsAndValue(call.getMetrics(), 0), 619 | }; 620 | 621 | this.functionsStats.set(call.getFunctionIdx(), stats); 622 | } 623 | 624 | stats.called++; 625 | let cycleDepth = call.getCycleDepth(); 626 | stats.maxCycleDepth = Math.max(stats.maxCycleDepth, cycleDepth); 627 | if (cycleDepth > 0) { 628 | continue; 629 | } 630 | 631 | stats.inc.add(call.getIncMetricValues()); 632 | stats.exc.add(call.getExcMetricValues()); 633 | } 634 | } 635 | 636 | getValues() { 637 | return Array.from(this.functionsStats.values()); 638 | } 639 | 640 | merge(other) { 641 | for (let key of other.functionsStats.keys()) { 642 | let a = this.functionsStats.get(key); 643 | let b = other.functionsStats.get(key); 644 | 645 | if (!a) { 646 | this.functionsStats.set(key, { 647 | functionName: b.functionName, 648 | maxCycleDepth: b.maxCycleDepth, 649 | called: b.called, 650 | inc: b.inc.copy(), 651 | exc: b.exc.copy(), 652 | }); 653 | 654 | continue; 655 | } 656 | 657 | a.called += b.called; 658 | a.maxCycleDepth = Math.max(a.maxCycleDepth, b.maxCycleDepth); 659 | 660 | a.inc.add(b.inc); 661 | a.exc.add(b.exc); 662 | } 663 | } 664 | } 665 | 666 | class CallTreeStatsNode { 667 | 668 | constructor(functionName, metrics) { 669 | this.functionName = functionName; 670 | this.parent = null; 671 | this.children = {}; 672 | this.minTime = Number.MAX_VALUE; 673 | this.called = 0; 674 | this.inc = MetricValueSet.createFromMetricsAndValue(metrics, 0); 675 | } 676 | 677 | getFunctionName() { 678 | return this.functionName; 679 | } 680 | 681 | getCalled() { 682 | return this.called; 683 | } 684 | 685 | getInc() { 686 | return this.inc; 687 | } 688 | 689 | getParent() { 690 | return this.parent; 691 | } 692 | 693 | getChildren() { 694 | return Object 695 | .keys(this.children) 696 | .map(k => this.children[k]) 697 | .sort((a, b) => a.minTime - b.minTime) 698 | ; 699 | } 700 | 701 | getDepth() { 702 | let depth = 0; 703 | let parent = this.getParent(); 704 | while (parent != null) { 705 | depth++; 706 | parent = parent.getParent(); 707 | } 708 | 709 | return depth; 710 | } 711 | 712 | getMinInc() { 713 | const minInc = this.inc.copy(); 714 | for (let i in this.children) { 715 | minInc.min(this.children[i].getMinInc()); 716 | } 717 | 718 | return minInc; 719 | } 720 | 721 | getMaxCumInc() { 722 | const maxCumInc = this.inc.copy().set(0); 723 | for (const i in this.children) { 724 | maxCumInc.add(this.children[i].getMaxCumInc()); 725 | } 726 | 727 | if (this.getChildren().length == 0) { 728 | maxCumInc.set(-Number.MAX_VALUE); 729 | } 730 | 731 | return maxCumInc.max(this.inc.copy()); 732 | } 733 | 734 | addChild(node) { 735 | node.parent = this; 736 | this.children[node.functionName] = node; 737 | 738 | return this; 739 | } 740 | 741 | addCallStats(call) { 742 | this.minTime = Math.min(this.minTime, call.getStart('wt')); 743 | this.called++; 744 | this.inc.add(call.getIncMetricValues()); 745 | 746 | return this; 747 | } 748 | 749 | merge(other) { 750 | this.called += other.called; 751 | this.inc.add(other.inc); 752 | this.minTime = Math.min(this.minTime, other.minTime); 753 | 754 | for (let i in other.children) { 755 | if (!(i in this.children)) { 756 | this.addChild(new CallTreeStatsNode( 757 | other.children[i].getFunctionName(), 758 | other.children[i].getInc().getMetrics() 759 | )); 760 | } 761 | 762 | this.children[i].merge(other.children[i]); 763 | } 764 | 765 | return this; 766 | } 767 | 768 | prune(minDuration) { 769 | for (let i in this.children) { 770 | const child = this.children[i]; 771 | 772 | if (child.called > 0 && child.inc.getValue('wt') < minDuration) { 773 | delete this.children[i]; 774 | 775 | continue; 776 | } 777 | 778 | child.prune(minDuration); 779 | } 780 | 781 | return this; 782 | } 783 | } 784 | 785 | class CallTreeStats { 786 | 787 | constructor(metrics, calls) { 788 | this.root = new CallTreeStatsNode(null, metrics); 789 | this.root.called = 1; 790 | 791 | calls = calls || []; 792 | for (let call of calls) { 793 | const stack = call.getStack(); 794 | 795 | let node = this.root; 796 | for (let i = 0; i < stack.length; i++) { 797 | const functionName = stack[i].getFunctionName(); 798 | let child = node.children[functionName]; 799 | if (!child) { 800 | child = new CallTreeStatsNode(functionName, metrics); 801 | node.addChild(child); 802 | } 803 | 804 | node = child; 805 | } 806 | 807 | node.addCallStats(call); 808 | if (node.getDepth() == 1) { 809 | node.getParent().getInc().add( 810 | call.getIncMetricValues() 811 | ); 812 | } 813 | } 814 | } 815 | 816 | getRoot() { 817 | return this.root; 818 | } 819 | 820 | merge(other) { 821 | this.root.merge(other.root); 822 | 823 | return this; 824 | } 825 | 826 | prune(minDuration) { 827 | this.root.prune(minDuration); 828 | 829 | return this; 830 | } 831 | } 832 | 833 | class TimeRangeStats { 834 | 835 | constructor(timeRange, functionsStats, callTreeStats, cumCostStats) { 836 | this.timeRange = timeRange; 837 | this.functionsStats = functionsStats; 838 | this.callTreeStats = callTreeStats; 839 | this.cumCostStats = cumCostStats; 840 | } 841 | 842 | merge(other) { 843 | this.functionsStats.merge(other.functionsStats); 844 | this.callTreeStats.merge(other.callTreeStats); 845 | this.cumCostStats.merge(other.cumCostStats); 846 | } 847 | 848 | getTimeRange() { 849 | return this.timeRange; 850 | } 851 | 852 | getFunctionsStats() { 853 | return this.functionsStats; 854 | } 855 | 856 | getCallTreeStats() { 857 | return this.callTreeStats; 858 | } 859 | 860 | getCumCostStats() { 861 | return this.cumCostStats; 862 | } 863 | } 864 | 865 | class CallRangeTree { 866 | 867 | constructor(range, callList, metricValuesList) { 868 | this.range = range; 869 | this.callList = callList; 870 | this.metricValuesList = metricValuesList; 871 | this.callRefs = []; 872 | this.children = []; 873 | this.functionsStats = null; 874 | this.callTreeStats = null; 875 | this.cumCostStats = null; 876 | } 877 | 878 | getNodeCount() { 879 | let nodeCount = 0; 880 | for (const child of this.children) { 881 | nodeCount += child.getNodeCount(); 882 | } 883 | 884 | return 1 + nodeCount; 885 | } 886 | 887 | getMaxDepth() { 888 | let maxDepth = 0; 889 | for (const child of this.children) { 890 | maxDepth = Math.max(maxDepth, child.getMaxDepth()); 891 | } 892 | 893 | return maxDepth + 1; 894 | } 895 | 896 | getTimeRangeStats(range, lowerBound, upperBound) { 897 | range = range || this.range; 898 | 899 | if (!this.range.overlaps(range)) { 900 | return new TimeRangeStats( 901 | range, 902 | new FunctionsStats(), 903 | new CallTreeStats(this.callList.getMetrics()), 904 | new CumCostStats(this.callList.getMetrics()) 905 | ); 906 | } 907 | 908 | if (this.range.isContainedBy(range)) { 909 | return new TimeRangeStats( 910 | range, 911 | this.functionsStats, 912 | this.callTreeStats, 913 | this.cumCostStats 914 | ); 915 | } 916 | 917 | if (lowerBound == null && this.range.begin < range.begin) { 918 | lowerBound = this.metricValuesList.getMetricValues(range.begin); 919 | } 920 | 921 | if (upperBound == null && this.range.end > range.end) { 922 | upperBound = this.metricValuesList.getMetricValues(range.end); 923 | } 924 | 925 | const calls = []; 926 | for (const callRef of this.callRefs) { 927 | const callTimeRange = this.callList.getCall(callRef).getTimeRange(); 928 | if (!callTimeRange.overlaps(range)) { 929 | continue; 930 | } 931 | 932 | calls.push(new TruncatedCallListEntry( 933 | this.callList.getCall(callRef), 934 | lowerBound, 935 | upperBound 936 | )); 937 | } 938 | 939 | const timeRangeStats = new TimeRangeStats( 940 | range, 941 | new FunctionsStats(calls), 942 | new CallTreeStats(this.callList.getMetrics(), calls), 943 | new CumCostStats(this.callList.getMetrics()) 944 | ); 945 | 946 | const remainingRange = this.range.copy().intersect(range); 947 | for (const child of this.children) { 948 | timeRangeStats.merge(child.getTimeRangeStats(range, lowerBound, upperBound)); 949 | remainingRange.sub(child.range); 950 | } 951 | 952 | timeRangeStats.getCumCostStats().merge( 953 | this.metricValuesList.getCumCostStats(remainingRange) 954 | ); 955 | 956 | return timeRangeStats; 957 | } 958 | 959 | getCallRefs(range, minDuration, callRefs) { 960 | if (this.range.length() < minDuration) { 961 | return []; 962 | } 963 | 964 | if (!this.range.overlaps(range)) { 965 | return []; 966 | } 967 | 968 | if (callRefs === undefined) { 969 | callRefs = []; 970 | } 971 | 972 | for (const callRef of this.callRefs) { 973 | const callTimeRange = this.callList.getCall(callRef).getTimeRange(); 974 | if (callTimeRange.length() < minDuration) { 975 | // since calls are sorted 976 | break; 977 | } 978 | 979 | if (!callTimeRange.overlaps(range)) { 980 | continue; 981 | } 982 | 983 | callRefs.push(callRef); 984 | } 985 | 986 | for (const child of this.children) { 987 | child.getCallRefs(range, minDuration, callRefs); 988 | } 989 | 990 | return callRefs; 991 | } 992 | 993 | static buildAsync(range, callRefs, callList, metricValuesList, progress, done) { 994 | const tree = new CallRangeTree(range, callList, metricValuesList); 995 | 996 | const lRange = tree.range.subRange(0.5, 0); 997 | const rRange = tree.range.subRange(0.5, 1); 998 | 999 | let lCallRefs = []; 1000 | let rCallRefs = []; 1001 | 1002 | if (!callRefs) { 1003 | callRefs = Array(callList.getSize()); 1004 | for (let i = 0; i < callRefs.length; i++) { 1005 | callRefs[i] = i; 1006 | } 1007 | } 1008 | 1009 | for (const callRef of callRefs) { 1010 | const callTimeRange = callList.getCall(callRef).getTimeRange(); 1011 | 1012 | if (!tree.range.contains(callTimeRange)) { 1013 | continue; 1014 | } 1015 | 1016 | if (lRange.contains(callTimeRange)) { 1017 | lCallRefs.push(callRef); 1018 | 1019 | continue; 1020 | } 1021 | 1022 | if (rRange.contains(callTimeRange)) { 1023 | rCallRefs.push(callRef); 1024 | 1025 | continue; 1026 | } 1027 | 1028 | tree.callRefs.push(callRef); 1029 | } 1030 | 1031 | const minCallsPerNode = 500; 1032 | 1033 | if (lCallRefs.length < minCallsPerNode) { 1034 | tree.callRefs = tree.callRefs.concat(lCallRefs); 1035 | lCallRefs = []; 1036 | } 1037 | 1038 | if (rCallRefs.length < minCallsPerNode) { 1039 | tree.callRefs = tree.callRefs.concat(rCallRefs); 1040 | rCallRefs = []; 1041 | } 1042 | 1043 | tree.callRefs.sort((a, b) => { 1044 | a = callList.getCall(a).getTimeRange().length(); 1045 | b = callList.getCall(b).getTimeRange().length(); 1046 | 1047 | // N.B. "b - a" does not work on Chromium 62.0.3202.94 !!! 1048 | 1049 | if (a == b) { 1050 | return 0; 1051 | } 1052 | 1053 | return a > b ? -1 : 1; 1054 | }); 1055 | 1056 | const treeCalls = []; 1057 | for (const callRef of tree.callRefs) { 1058 | treeCalls.push(callList.getCall(callRef)); 1059 | } 1060 | 1061 | tree.functionsStats = new FunctionsStats(treeCalls); 1062 | tree.callTreeStats = new CallTreeStats(callList.getMetrics(), treeCalls); 1063 | tree.cumCostStats = new CumCostStats(callList.getMetrics()); 1064 | 1065 | utils.processCallChain([ 1066 | next => { 1067 | progress(tree.callRefs.length); 1068 | next(); 1069 | }, 1070 | next => { 1071 | if (lCallRefs.length == 0) { 1072 | tree.cumCostStats.merge(metricValuesList.getCumCostStats(lRange)); 1073 | next(); 1074 | 1075 | return; 1076 | } 1077 | 1078 | tree.children.push(CallRangeTree.buildAsync( 1079 | lRange, 1080 | lCallRefs, 1081 | callList, 1082 | metricValuesList, 1083 | progress, 1084 | child => { 1085 | tree.functionsStats.merge(child.functionsStats); 1086 | tree.callTreeStats.merge(child.callTreeStats); 1087 | tree.cumCostStats.merge(child.cumCostStats); 1088 | next(); 1089 | } 1090 | )); 1091 | }, 1092 | next => { 1093 | if (rCallRefs.length == 0) { 1094 | tree.cumCostStats.merge(metricValuesList.getCumCostStats(rRange)); 1095 | next(); 1096 | 1097 | return; 1098 | } 1099 | 1100 | tree.children.push(CallRangeTree.buildAsync( 1101 | rRange, 1102 | rCallRefs, 1103 | callList, 1104 | metricValuesList, 1105 | progress, 1106 | child => { 1107 | tree.functionsStats.merge(child.functionsStats); 1108 | tree.callTreeStats.merge(child.callTreeStats); 1109 | tree.cumCostStats.merge(child.cumCostStats); 1110 | next(); 1111 | } 1112 | )); 1113 | }, 1114 | () => { 1115 | // prune calls < 1/150th of node range as memory / accuracy trade-off 1116 | // FIXME /!\ this should be tunable, pruning on time basis only could broke accuracy on other metrics 1117 | // FIXME /!\ pruning appears to cause popping noise in flamegraph view 1118 | tree.callTreeStats.prune(range.length() / 150); 1119 | done(tree); 1120 | } 1121 | ], callRefs.length >= 5000, 0); 1122 | 1123 | return tree; 1124 | } 1125 | } 1126 | 1127 | class ProfileData { 1128 | 1129 | constructor(metricsInfo, metadata, stats, callList, metricValuesList, callRangeTree) { 1130 | console.log('tree', callRangeTree.getMaxDepth(), callRangeTree.getNodeCount(), callList.getSize()); 1131 | this.metricsInfo = metricsInfo; 1132 | this.metadata = metadata; 1133 | this.stats = stats; 1134 | this.callList = callList; 1135 | this.metricValuesList = metricValuesList; 1136 | this.callRangeTree = callRangeTree; 1137 | } 1138 | 1139 | getMetricKeys() { 1140 | return Object.keys(this.metricsInfo); 1141 | } 1142 | 1143 | getMetricInfo(metric) { 1144 | for (let info of this.metricsInfo) { 1145 | if (info.key == metric) { 1146 | return info; 1147 | } 1148 | } 1149 | 1150 | throw new Error('Unknown metric: ' + key); 1151 | } 1152 | 1153 | getMetricFormatter(metric) { 1154 | switch (this.getMetricInfo(metric).type) { 1155 | case 'time': 1156 | return fmt.time; 1157 | 1158 | case 'memory': 1159 | return fmt.memory; 1160 | 1161 | default: 1162 | return fmt.quantity; 1163 | } 1164 | } 1165 | 1166 | isReleasableMetric(metric) { 1167 | return this.getMetricInfo(metric).releasable; 1168 | } 1169 | 1170 | getMetadata() { 1171 | return this.metadata; 1172 | } 1173 | 1174 | getStats() { 1175 | return this.stats; 1176 | } 1177 | 1178 | getWallTime() { 1179 | return this.stats.getMax('wt'); 1180 | } 1181 | 1182 | getTimeRange() { 1183 | return new math.Range( 1184 | 0, 1185 | this.getWallTime() 1186 | ); 1187 | } 1188 | 1189 | getTimeRangeStats(range) { 1190 | console.time('getTimeRangeStats'); 1191 | 1192 | const timeRangeStats = this 1193 | .callRangeTree 1194 | .getTimeRangeStats(range) 1195 | ; 1196 | 1197 | console.timeEnd('getTimeRangeStats'); 1198 | 1199 | return timeRangeStats; 1200 | } 1201 | 1202 | getCall(idx) { 1203 | return this.callList.getCall(idx); 1204 | } 1205 | 1206 | getCalls(range, minDuration) { 1207 | console.time('getCalls'); 1208 | const callRefs = this.callRangeTree.getCallRefs( 1209 | range, 1210 | minDuration 1211 | ); 1212 | 1213 | let calls = []; 1214 | for (let callRef of callRefs) { 1215 | calls.push(this.callList.getCall(callRef)); 1216 | } 1217 | 1218 | console.timeEnd('getCalls'); 1219 | 1220 | return calls; 1221 | } 1222 | 1223 | getMetricValues(time) { 1224 | return this.metricValuesList.getMetricValues(time); 1225 | } 1226 | } 1227 | 1228 | export class ProfileDataBuilder { 1229 | 1230 | constructor(metricsInfo) { 1231 | this.metricsInfo = metricsInfo; 1232 | } 1233 | 1234 | setMetadata(metadata) { 1235 | this.metadata = metadata; 1236 | this.metrics = metadata.enabled_metrics; 1237 | this.stats = new Stats(this.metrics); 1238 | 1239 | this.totalCallCount = metadata.recorded_call_count; 1240 | this.currentCallCount = 0; 1241 | 1242 | this.callList = new CallList( 1243 | metadata.called_function_count, 1244 | this.metrics 1245 | ); 1246 | 1247 | this.metricValuesList = new MetricValuesList( 1248 | this.metrics 1249 | ); 1250 | 1251 | this.stack = []; 1252 | this.eventCount = 0; 1253 | this.callCount = 0; 1254 | } 1255 | 1256 | getTotalCallCount() { 1257 | return this.totalCallCount; 1258 | } 1259 | 1260 | getCurrentCallCount() { 1261 | return this.currentCallCount; 1262 | } 1263 | 1264 | addEvent(event) { 1265 | if (event[1]) { 1266 | this.stack.push({ 1267 | idx: this.callCount++, 1268 | startEvent: event, 1269 | startEventIdx: this.eventCount++, 1270 | fnIdx: event[0], 1271 | parent: this.stack.length > 0 ? this.stack[this.stack.length - 1] : null, 1272 | start: Array(this.metrics.length).fill(0), 1273 | end: Array(this.metrics.length).fill(0), 1274 | exc: Array(this.metrics.length).fill(0), 1275 | children: Array(this.metrics.length).fill(0), 1276 | }); 1277 | 1278 | return; 1279 | } 1280 | 1281 | const frame = this.stack.pop(); 1282 | 1283 | frame.endEventIdx = this.eventCount++; 1284 | 1285 | for (let j = 0; j < this.metrics.length; j++) { 1286 | const m = this.metrics[j]; 1287 | frame.start[j] = frame.startEvent[2 + j]; 1288 | frame.end[j] = event[2 + j]; 1289 | 1290 | this.stats.mergeMetricValue(m, frame.start[j]); 1291 | this.stats.mergeMetricValue(m, frame.end[j]); 1292 | this.stats.mergeCallMetricValue(m, frame.end[j] - frame.start[j]); 1293 | } 1294 | 1295 | this.metricValuesList.setRawMetricValuesData(frame.startEventIdx, frame.start); 1296 | this.metricValuesList.setRawMetricValuesData(frame.endEventIdx, frame.end); 1297 | 1298 | for (let j = 0; j < this.metrics.length; j++) { 1299 | frame.exc[j] = frame.end[j] - frame.start[j]; 1300 | if (j in frame.children) { 1301 | frame.exc[j] -= frame.children[j]; 1302 | } 1303 | } 1304 | 1305 | if (this.stack.length > 0) { 1306 | let parent = this.stack[this.stack.length - 1]; 1307 | for (let j = 0; j < this.metrics.length; j++) { 1308 | parent.children[j] += frame.end[j] - frame.start[j]; 1309 | } 1310 | 1311 | for (let k = this.stack.length - 1; k >= 0; k--) { 1312 | if (this.stack[k].fnIdx == frame.fnIdx) { 1313 | for (let j = 0; j < this.metrics.length; j++) { 1314 | this.stack[k].children[j] -= frame.exc[j]; 1315 | } 1316 | 1317 | break; 1318 | } 1319 | } 1320 | } 1321 | 1322 | this.currentCallCount++; 1323 | 1324 | this.callList.setRawCallData( 1325 | frame.idx, 1326 | frame.fnIdx, 1327 | frame.parent != null ? frame.parent.idx : -1, 1328 | frame.start, 1329 | frame.end, 1330 | frame.exc 1331 | ); 1332 | } 1333 | 1334 | setFunctionName(idx, name) { 1335 | this.callList.setFunctionName(idx, name); 1336 | } 1337 | 1338 | buildCallRangeTree(setProgress) { 1339 | return new Promise(resolve => { 1340 | let totalInserted = 0; 1341 | console.time('Call range tree building'); 1342 | CallRangeTree.buildAsync( 1343 | new math.Range(0, this.stats.getMax('wt')), 1344 | null, 1345 | this.callList, 1346 | this.metricValuesList, 1347 | inserted => { 1348 | totalInserted += inserted; 1349 | setProgress(totalInserted, this.callList.getSize()); 1350 | }, 1351 | callRangeTree => { 1352 | console.timeEnd('Call range tree building'); 1353 | this.callRangeTree = callRangeTree; 1354 | resolve(); 1355 | } 1356 | ); 1357 | }); 1358 | } 1359 | 1360 | getProfileData() { 1361 | return new ProfileData( 1362 | this.metricsInfo, 1363 | this.metadata, 1364 | this.stats, 1365 | this.callList, 1366 | this.metricValuesList, 1367 | this.callRangeTree 1368 | ); 1369 | } 1370 | } 1371 | -------------------------------------------------------------------------------- /include/php-spx/assets/web-ui/js/widget.js: -------------------------------------------------------------------------------- 1 | /* SPX - A simple profiler for PHP 2 | * Copyright (C) 2017-2020 Sylvain Lassaut 3 | * 4 | * This program is free software: you can redistribute it and/or modify 5 | * it under the terms of the GNU General Public License as published by 6 | * the Free Software Foundation; either version 3 of the License, or 7 | * (at your option) any later version. 8 | * 9 | * This program is distributed in the hope that it will be useful, 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | * GNU General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | */ 17 | 18 | 19 | import * as utils from './?SPX_UI_URI=/js/utils.js'; 20 | import * as fmt from './?SPX_UI_URI=/js/fmt.js'; 21 | import * as math from './?SPX_UI_URI=/js/math.js'; 22 | import * as svg from './?SPX_UI_URI=/js/svg.js'; 23 | 24 | function getCallMetricValueColor(profileData, metric, value) { 25 | const metricRange = profileData.getStats().getCallRange(metric); 26 | 27 | let scaleValue = 0; 28 | 29 | // this bounding is required since value can be lower than the lowest sample 30 | // (represented by metricRange.begin). It is the case when value is interpolated 31 | // from 2 consecutive samples 32 | value = Math.max(metricRange.begin, value) 33 | 34 | if (metricRange.length() > 100) { 35 | scaleValue = 36 | Math.log10(value - metricRange.begin) 37 | / Math.log10(metricRange.length()) 38 | ; 39 | } else { 40 | scaleValue = metricRange.lerp(value); 41 | } 42 | 43 | return math.Vec3.lerpPath( 44 | [ 45 | new math.Vec3(0, 0.3, 0.9), 46 | new math.Vec3(0, 0.9, 0.9), 47 | new math.Vec3(0, 0.9, 0), 48 | new math.Vec3(0.9, 0.9, 0), 49 | new math.Vec3(0.9, 0.2, 0), 50 | ], 51 | scaleValue 52 | ).toHTMLColor(); 53 | } 54 | 55 | function getFunctionCategoryColor(funcName) { 56 | let categories = utils.getCategories(true); 57 | for (let category of categories) { 58 | for (let pattern of category.patterns) { 59 | if (pattern.test(funcName)) { 60 | pattern.lastIndex = 0; 61 | return `rgb(${category.color[0]},${category.color[1]},${category.color[2]})`; 62 | } 63 | } 64 | } 65 | } 66 | 67 | function renderSVGTimeGrid(viewPort, timeRange, detailed) { 68 | const delta = timeRange.length(); 69 | let step = Math.pow(10, parseInt(Math.log10(delta))); 70 | if (delta / step < 4) { 71 | step /= 5; 72 | } 73 | 74 | // 5 as min value so that minor step is lower bounded to 1 75 | step = Math.max(step, 5); 76 | 77 | const minorStep = step / 5; 78 | 79 | let tickTime = (parseInt(timeRange.begin / minorStep) + 1) * minorStep; 80 | while (1) { 81 | const majorTick = tickTime % step == 0; 82 | const x = viewPort.width * (tickTime - timeRange.begin) / delta; 83 | viewPort.appendChildToFragment(svg.createNode('line', { 84 | x1: x, 85 | y1: 0, 86 | x2: x, 87 | y2: viewPort.height, 88 | stroke: '#777', 89 | 'stroke-width': majorTick ? 0.5 : 0.2 90 | })); 91 | 92 | if (majorTick) { 93 | if (detailed) { 94 | const units = ['s', 'ms', 'us', 'ns']; 95 | let t = tickTime; 96 | let line = 0; 97 | while (t > 0 && units.length > 0) { 98 | const unit = units.pop(); 99 | let m = t; 100 | if (units.length > 0) { 101 | m = m % 1000; 102 | t = parseInt(t / 1000); 103 | } 104 | 105 | if (m == 0) { 106 | continue; 107 | } 108 | 109 | viewPort.appendChildToFragment(svg.createNode('text', { 110 | x: x + 2, 111 | y: viewPort.height - 10 - 20 * line++, 112 | width: 100, 113 | height: 15, 114 | 'font-size': 12, 115 | fill: line > 1 ? '#777' : '#ccc', 116 | }, node => { 117 | node.textContent = m + unit; 118 | })); 119 | } 120 | } else { 121 | viewPort.appendChildToFragment(svg.createNode( 122 | 'text', 123 | { 124 | x: x + 2, 125 | y: viewPort.height - 10, 126 | width: 100, 127 | height: 15, 128 | 'font-size': 12, 129 | fill: '#aaa', 130 | }, 131 | node => node.textContent = fmt.time(tickTime) 132 | )); 133 | } 134 | } 135 | 136 | tickTime += minorStep; 137 | if (tickTime > timeRange.end) { 138 | break; 139 | } 140 | } 141 | } 142 | 143 | function renderSVGMultiLineText(viewPort, lines) { 144 | let y = 15; 145 | 146 | const text = svg.createNode('text', { 147 | x: 0, 148 | y: y, 149 | 'font-size': 12, 150 | fill: '#fff', 151 | }); 152 | 153 | viewPort.appendChild(text); 154 | 155 | for (let line of lines) { 156 | text.appendChild(svg.createNode( 157 | 'tspan', 158 | { 159 | x: 0, 160 | y: y, 161 | }, 162 | node => node.textContent = line 163 | )); 164 | 165 | y += 15; 166 | } 167 | } 168 | 169 | function renderSVGMetricValuesPlot(viewPort, profileData, metric, timeRange) { 170 | const timeComponentMetric = ['ct', 'it'].includes(metric); 171 | const valueRange = timeComponentMetric ? new math.Range(0, 1) : profileData.getStats().getRange(metric); 172 | 173 | const step = 4; 174 | let previousMetricValues = null; 175 | let points = []; 176 | console.time('renderSVGMetricValuesPlot') 177 | for (let i = 0; i < viewPort.width; i += step) { 178 | const currentMetricValues = profileData.getMetricValues( 179 | timeRange.lerp(i / viewPort.width) 180 | ); 181 | 182 | if (timeComponentMetric && previousMetricValues == null) { 183 | previousMetricValues = currentMetricValues; 184 | 185 | continue; 186 | } 187 | 188 | let currentValue = currentMetricValues.getValue(metric); 189 | if (timeComponentMetric) { 190 | currentValue = (currentMetricValues.getValue(metric) - previousMetricValues.getValue(metric)) 191 | / (currentMetricValues.getValue('wt') - previousMetricValues.getValue('wt')) 192 | ; 193 | } 194 | 195 | points.push(i); 196 | points.push(parseInt( 197 | viewPort.height * ( 198 | 1 - valueRange.lerpDist(currentValue) 199 | ) 200 | )); 201 | 202 | previousMetricValues = currentMetricValues; 203 | } 204 | 205 | console.timeEnd('renderSVGMetricValuesPlot') 206 | 207 | viewPort.appendChildToFragment(svg.createNode('polyline', { 208 | points: points.join(' '), 209 | stroke: '#0af', 210 | 'stroke-width': 2, 211 | fill: 'none', 212 | })); 213 | 214 | const tickValueStep = valueRange.lerp(0.25); 215 | let tickValue = tickValueStep; 216 | while (tickValue < valueRange.end) { 217 | const y = parseInt(viewPort.height * (1 - valueRange.lerpDist(tickValue))); 218 | 219 | viewPort.appendChildToFragment(svg.createNode('line', { 220 | x1: 0, 221 | y1: y, 222 | x2: viewPort.width, 223 | y2: y, 224 | stroke: '#777', 225 | 'stroke-width': 0.5 226 | })); 227 | 228 | viewPort.appendChildToFragment(svg.createNode('text', { 229 | x: 10, 230 | y: y - 5, 231 | width: 100, 232 | height: 15, 233 | 'font-size': 12, 234 | fill: '#aaa', 235 | }, node => { 236 | const formatter = timeComponentMetric ? fmt.pct : profileData.getMetricFormatter(metric); 237 | node.textContent = formatter(tickValue); 238 | })); 239 | 240 | tickValue += tickValueStep; 241 | } 242 | } 243 | 244 | class ViewTimeRange { 245 | 246 | constructor(timeRange, wallTime, viewWidth) { 247 | this.setTimeRange(timeRange); 248 | this.wallTime = wallTime; 249 | this.setViewWidth(viewWidth); 250 | } 251 | 252 | setTimeRange(timeRange) { 253 | this.timeRange = timeRange.copy(); 254 | } 255 | 256 | setViewWidth(viewWidth) { 257 | this.viewWidth = viewWidth; 258 | } 259 | 260 | fix() { 261 | const minLength = 3; 262 | this.timeRange.bound(0, this.wallTime); 263 | if (this.timeRange.length() >= minLength) { 264 | return this; 265 | } 266 | 267 | this.timeRange.end = this.timeRange.begin + minLength; 268 | if (this.timeRange.end > this.wallTime) { 269 | this.timeRange.shift(this.wallTime - this.timeRange.end); 270 | } 271 | 272 | return this; 273 | } 274 | 275 | shiftViewRange(dist) { 276 | this.timeRange = this._viewRangeToTimeRange( 277 | this.getViewRange().shift(dist) 278 | ); 279 | 280 | return this.fix(); 281 | } 282 | 283 | shiftViewRangeBegin(dist) { 284 | this.timeRange = this._viewRangeToTimeRange( 285 | this.getViewRange().shiftBegin(dist) 286 | ); 287 | 288 | return this.fix(); 289 | } 290 | 291 | shiftViewRangeEnd(dist) { 292 | this.timeRange = this._viewRangeToTimeRange( 293 | this.getViewRange().shiftEnd(dist) 294 | ); 295 | 296 | return this.fix(); 297 | } 298 | 299 | shiftScaledViewRange(dist) { 300 | return this.shiftViewRange(dist / this.getScale()); 301 | } 302 | 303 | zoomScaledViewRange(factor, center) { 304 | center /= this.getScale(); // scaled 305 | center += this.getViewRange().begin; // translated 306 | center /= this.viewWidth; // view space -> norm space 307 | center *= this.wallTime; // norm space -> time space 308 | 309 | this.timeRange.shift(-center); 310 | this.timeRange.scale(1 / factor); 311 | this.timeRange.shift(center); 312 | 313 | return this.fix(); 314 | } 315 | 316 | getScale() { 317 | return this.wallTime / this.timeRange.length(); 318 | } 319 | 320 | getViewRange() { 321 | return this._timeRangeToViewRange(this.timeRange); 322 | } 323 | 324 | getScaledViewRange() { 325 | return this.getViewRange().scale(this.getScale()); 326 | } 327 | 328 | getTimeRange() { 329 | return this.timeRange.copy(); 330 | } 331 | 332 | _viewRangeToTimeRange(range) { 333 | return range.copy().scale(this.wallTime / this.viewWidth); 334 | } 335 | 336 | _timeRangeToViewRange(range) { 337 | return range.copy().scale(this.viewWidth / this.wallTime); 338 | } 339 | } 340 | 341 | class ViewPort { 342 | 343 | constructor(width, height, x, y) { 344 | this.width = width; 345 | this.height = height; 346 | this.x = x || 0; 347 | this.y = y || 0; 348 | 349 | this.node = svg.createNode('svg', { 350 | width: this.width, 351 | height: this.height, 352 | x: this.x, 353 | y: this.y, 354 | }); 355 | 356 | this.fragment = null; 357 | } 358 | 359 | createSubViewPort(width, height, x, y) { 360 | const viewPort = new ViewPort(width, height, x, y); 361 | this.appendChild(viewPort.node); 362 | 363 | return viewPort; 364 | } 365 | 366 | resize(width, height) { 367 | this.width = width; 368 | this.height = height; 369 | this.node.setAttribute('width', this.width); 370 | this.node.setAttribute('height', this.height); 371 | } 372 | 373 | appendChildToFragment(child) { 374 | if (!this.fragment) { 375 | this.fragment = document.createDocumentFragment(); 376 | } 377 | 378 | this.fragment.appendChild(child); 379 | } 380 | 381 | flushFragment() { 382 | if (!this.fragment) { 383 | return; 384 | } 385 | 386 | this.appendChild(this.fragment); 387 | this.fragment = null; 388 | } 389 | 390 | appendChild(child) { 391 | this.node.appendChild(child); 392 | } 393 | 394 | clear() { 395 | this.node.innerHTML = null; 396 | } 397 | } 398 | 399 | class Widget { 400 | 401 | constructor(container, profileData) { 402 | this.container = container; 403 | this.profileData = profileData; 404 | this.timeRange = profileData.getTimeRange(); 405 | this.timeRangeStats = profileData.getTimeRangeStats(this.timeRange); 406 | this.currentMetric = profileData.getMetadata().enabled_metrics[0]; 407 | this.repaintTimeout = null; 408 | this.resizingTimeouts = []; 409 | this.colorSchemeMode = null; 410 | this.highlightedFunctionName = null; 411 | this.functionColorResolver = (functionName, defaultColor) => { 412 | let color; 413 | switch (this.colorSchemeMode) { 414 | case ColorSchemeManager.MODE_CATEGORY: 415 | color = getFunctionCategoryColor(functionName); 416 | break; 417 | 418 | default: 419 | color = defaultColor; 420 | } 421 | 422 | if (this.highlightedFunctionName) { 423 | color = math.Vec3 424 | .createFromHTMLColor(color) 425 | .mult(functionName == this.highlightedFunctionName ? 1.5 : 0.33) 426 | .toHTMLColor() 427 | ; 428 | } 429 | 430 | return color; 431 | }; 432 | 433 | $(window).on('resize', () => this.handleResize()); 434 | 435 | $(window).on('spx-timerange-update', (e, timeRange, timeRangeStats) => { 436 | this.timeRange = timeRange; 437 | this.timeRangeStats = timeRangeStats; 438 | 439 | this.onTimeRangeUpdate(); 440 | }); 441 | 442 | $(window).on('spx-colorscheme-mode-update', (e, colorSchemeMode) => { 443 | this.colorSchemeMode = colorSchemeMode; 444 | 445 | this.onColorSchemeModeUpdate(); 446 | }); 447 | 448 | $(window).on('spx-colorscheme-category-update', () => { 449 | if (this.colorSchemeMode != ColorSchemeManager.MODE_CATEGORY) { 450 | return; 451 | } 452 | 453 | this.onColorSchemeCategoryUpdate(); 454 | }); 455 | 456 | $(window).on('spx-highlighted-function-update', (e, highlightedFunctionName) => { 457 | this.highlightedFunctionName = highlightedFunctionName; 458 | 459 | this.onHighlightedFunctionUpdate(); 460 | }); 461 | } 462 | 463 | onTimeRangeUpdate() { 464 | } 465 | 466 | onColorSchemeModeUpdate() { 467 | this.repaint(); 468 | } 469 | 470 | onColorSchemeCategoryUpdate() { 471 | this.repaint(); 472 | } 473 | 474 | onHighlightedFunctionUpdate() { 475 | this.repaint(); 476 | } 477 | 478 | notifyTimeRangeUpdate(timeRange) { 479 | this.timeRange = timeRange; 480 | this.timeRangeStats = this.profileData.getTimeRangeStats(this.timeRange); 481 | 482 | $(window).trigger('spx-timerange-update', [this.timeRange, this.timeRangeStats]); 483 | } 484 | 485 | notifyColorSchemeModeUpdate(colorSchemeMode) { 486 | this.colorSchemeMode = colorSchemeMode; 487 | $(window).trigger('spx-colorscheme-mode-update', [this.colorSchemeMode]); 488 | } 489 | 490 | notifyColorSchemeCategoryUpdate() { 491 | $(window).trigger('spx-colorscheme-category-update'); 492 | } 493 | 494 | setCurrentMetric(metric) { 495 | this.currentMetric = metric; 496 | } 497 | 498 | clear() { 499 | this.container.empty(); 500 | } 501 | 502 | handleResize() { 503 | for (const t of this.resizingTimeouts) { 504 | clearTimeout(t); 505 | } 506 | 507 | this.resizingTimeouts = []; 508 | 509 | const handle = () => { 510 | this.onContainerResize(); 511 | this.repaint(); 512 | }; 513 | 514 | // Several delayed handler() calls are required to both optimize responsiveness and fix 515 | // the appearing/disappearing scrollbar issue. 516 | 517 | handle(); 518 | this.resizingTimeouts.push(setTimeout(handle, 80)); 519 | this.resizingTimeouts.push(setTimeout(handle, 800)); 520 | this.resizingTimeouts.push(setTimeout(handle, 1500)); 521 | } 522 | 523 | onContainerResize() { 524 | } 525 | 526 | render() { 527 | } 528 | 529 | repaint() { 530 | if (this.repaintTimeout !== null) { 531 | return; 532 | } 533 | 534 | this.repaintTimeout = setTimeout( 535 | () => { 536 | this.repaintTimeout = null; 537 | 538 | const id = this.container.attr('id'); 539 | console.time('repaint ' + id); 540 | console.time('clear ' + id); 541 | this.clear(); 542 | console.timeEnd('clear ' + id); 543 | console.time('render ' + id); 544 | this.render(); 545 | console.timeEnd('render ' + id); 546 | console.timeEnd('repaint ' + id); 547 | }, 548 | 0 549 | ); 550 | } 551 | } 552 | 553 | export class ColorSchemeManager extends Widget { 554 | 555 | static get MODE_DEFAULT() { return 'default'; } 556 | static get MODE_CATEGORY() { return 'category'; } 557 | 558 | constructor(container, profileData) { 559 | super(container, profileData); 560 | 561 | // FIXME remove DOM dependencies like element ids 562 | 563 | this.container.html( 564 | ` 565 | Color scheme: default 566 |
567 | 574 | 577 | 583 | 586 |
587 | 588 |
    589 |
    590 | ` 591 | ); 592 | 593 | this.panelOpen = false; 594 | 595 | this.toggleLink = this.container.find('#colorscheme-current-name'); 596 | this.panel = this.container.find('#colorscheme-panel'); 597 | this.categoryList = this.container.find('#colorscheme-panel ol'); 598 | 599 | this.toggleLink.on('click', e => { 600 | e.preventDefault(); 601 | this.togglePanel(); 602 | }); 603 | 604 | $('#new-category').on('click', e => { 605 | e.preventDefault(); 606 | const cats = utils.getCategories(); 607 | cats.unshift({ 608 | color: [90, 90, 90], 609 | label: 'untitled', 610 | patterns: [] 611 | }); 612 | 613 | utils.setCategories(cats); 614 | 615 | this.repaint(); 616 | }); 617 | 618 | this.container.find('input[name="colorscheme-mode"]:radio').on('change', e => { 619 | if (!e.target.checked) { 620 | return 621 | } 622 | 623 | const label = this.panel.find(`label[for="${e.target.id}"]`); 624 | this.toggleLink.html(label.html()); 625 | 626 | this.notifyColorSchemeModeUpdate(e.target.value); 627 | }); 628 | 629 | this.categoryList.on('input', 'textarea', e => { 630 | e.target.style.height = 'auto'; 631 | e.target.style.height = e.target.scrollHeight + 'px'; 632 | }); 633 | 634 | const editHandler = e => { 635 | this.onCategoryEdit(e.target); 636 | e.stopPropagation(); 637 | }; 638 | 639 | this.categoryList.on('change', '.jscolor,input,textarea', editHandler); 640 | this.categoryList.on('click', 'button', editHandler); 641 | } 642 | 643 | onColorSchemeCategoryUpdate() { 644 | } 645 | 646 | clear() { 647 | 648 | } 649 | 650 | render() { 651 | const categories = utils.getCategories(); 652 | const hex = n => n.toString(16).padStart(2, "0"); 653 | 654 | const items = categories.map((cat, i) => { 655 | return ` 656 |
  1. 657 | 662 | 663 | 664 | 665 | 666 | 667 |
  2. `; 668 | }); 669 | 670 | this.categoryList.html(items.join('')); 671 | this.categoryList.find('textarea').trigger('input'); 672 | this.categoryList.find('.jscolor').each((i, el) => { 673 | el.picker = new jscolor(el, { 674 | width: 101, 675 | padding: 0, 676 | shadow: false, 677 | borderWidth: 0, 678 | backgroundColor: 'transparent', 679 | insetColor: '#000' 680 | }); 681 | 682 | if (this.openPicker === i) { 683 | this.openPicker = null; 684 | el.picker.show(); 685 | } 686 | }); 687 | } 688 | 689 | togglePanel() { 690 | this.panelOpen = !this.panelOpen; 691 | this.panel.toggle(); 692 | if (this.panelOpen) { 693 | this.repaint(); 694 | setTimeout(() => this.listenForPanelClose(), 0); 695 | } else { 696 | this.panel.find('.jscolor').each((_, e) => e.picker.hide()); 697 | } 698 | } 699 | 700 | listenForPanelClose() { 701 | const onOutsideClick = e => { 702 | if ( 703 | !!e.target._jscControlName 704 | || e.target.closest('#colorscheme-panel') !== null 705 | ) { 706 | return; 707 | } 708 | 709 | e.preventDefault(); 710 | off(); 711 | this.togglePanel(); 712 | }; 713 | 714 | const onEscKey = e => { 715 | if (e.key != 'Escape') { return; } 716 | off(); 717 | this.togglePanel(); 718 | }; 719 | 720 | const off = () => { 721 | $(document).off('mousedown', onOutsideClick); 722 | $(document).off('keydown', onEscKey); 723 | }; 724 | 725 | $(document).on('mousedown', onOutsideClick); 726 | $(document).on('keydown', onEscKey); 727 | } 728 | 729 | onCategoryEdit(elem) { 730 | const idx = parseInt(elem.closest('li').dataset['index'], 10); 731 | const categories = utils.getCategories(); 732 | 733 | const pushTarget = Math.max(idx-1, 0); 734 | switch (elem.name) { 735 | case 'push-down': 736 | pushTarget = Math.min(idx+1, categories.length-1); 737 | case 'push-up': 738 | categories.splice(pushTarget, 0, categories.splice(idx, 1)[0]); 739 | break; 740 | 741 | case 'del': 742 | categories.splice(idx, 1); 743 | break; 744 | 745 | case 'colorpicker': 746 | this.openPicker = idx; 747 | categories[idx].color = elem.picker.rgb.map(n => Math.floor(n)); 748 | break; 749 | 750 | case 'label': 751 | categories[idx].label = elem.value.trim(); 752 | break; 753 | 754 | case 'patterns': 755 | const regexes = elem.value 756 | .split(/[\r\n]+/) 757 | .map(line => line.trim()) 758 | .filter(line => line != '') 759 | .map(line => new RegExp(line, 'gi')); 760 | 761 | categories[idx].patterns = regexes; 762 | break; 763 | 764 | default: 765 | throw new Error(`Unknown category prop '${elem.name}'`); 766 | } 767 | 768 | utils.setCategories(categories); 769 | this.repaint(); 770 | this.notifyColorSchemeCategoryUpdate(); 771 | } 772 | } 773 | 774 | class SVGWidget extends Widget { 775 | 776 | constructor(container, profileData) { 777 | super(container, profileData); 778 | 779 | this.viewPort = new ViewPort( 780 | this.container.width(), 781 | this.container.height() 782 | ); 783 | 784 | this.container.append(this.viewPort.node); 785 | } 786 | 787 | clear() { 788 | this.viewPort.clear(); 789 | } 790 | 791 | onContainerResize() { 792 | super.onContainerResize() 793 | 794 | // viewPort internal svg shrinking is first required to let the container get 795 | // its actual size. 796 | this.viewPort.resize(0, 0); 797 | 798 | this.viewPort.resize( 799 | this.container.width(), 800 | this.container.height() 801 | ); 802 | } 803 | } 804 | 805 | export class ColorScale extends SVGWidget { 806 | 807 | constructor(container, profileData) { 808 | super(container, profileData); 809 | } 810 | 811 | onColorSchemeModeUpdate() { 812 | if (this.colorSchemeMode == ColorSchemeManager.MODE_DEFAULT) { 813 | this.container.show(() => this.repaint()); 814 | } else { 815 | this.container.hide(); 816 | } 817 | } 818 | 819 | render() { 820 | const step = 8; 821 | const exp = 5; 822 | 823 | const getCurrentMetricValue = x => { 824 | return this 825 | .profileData 826 | .getStats() 827 | .getCallRange(this.currentMetric) 828 | .lerp( 829 | Math.pow(x, exp) / Math.pow(this.viewPort.width, exp) 830 | ) 831 | ; 832 | } 833 | 834 | for (let i = 0; i < this.viewPort.width; i += step) { 835 | this.viewPort.appendChildToFragment(svg.createNode('rect', { 836 | x: i, 837 | y: 0, 838 | width: step, 839 | height: this.viewPort.height, 840 | fill: getCallMetricValueColor( 841 | this.profileData, 842 | this.currentMetric, 843 | getCurrentMetricValue(i) 844 | ), 845 | })); 846 | } 847 | 848 | for (let i = 0; i < this.viewPort.width; i += step * 20) { 849 | this.viewPort.appendChildToFragment(svg.createNode('text', { 850 | x: i, 851 | y: this.viewPort.height - 5, 852 | width: 100, 853 | height: 15, 854 | 'font-size': 12, 855 | fill: '#777', 856 | }, node => { 857 | node.textContent = this.profileData.getMetricFormatter(this.currentMetric)( 858 | getCurrentMetricValue(i) 859 | ); 860 | })); 861 | } 862 | 863 | this.viewPort.flushFragment(); 864 | } 865 | } 866 | 867 | export class CategoryLegend extends SVGWidget { 868 | 869 | constructor(container, profileData) { 870 | super(container, profileData); 871 | } 872 | 873 | onColorSchemeModeUpdate() { 874 | if (this.colorSchemeMode == ColorSchemeManager.MODE_CATEGORY) { 875 | this.container.show(() => this.repaint()); 876 | } else { 877 | this.container.hide(); 878 | } 879 | } 880 | 881 | render() { 882 | let categories = utils.getCategories(true); 883 | let width = this.viewPort.width / categories.length; 884 | 885 | for (let i = 0; i < categories.length; i++) { 886 | let category = categories[i]; 887 | let [r, g, b] = category.color; 888 | this.viewPort.appendChildToFragment(svg.createNode('rect', { 889 | x: width * i, 890 | y: 0, 891 | width, 892 | height: this.viewPort.height, 893 | fill: `rgb(${r},${g},${b})`, 894 | })); 895 | this.viewPort.appendChildToFragment(svg.createNode('text', { 896 | x: width * i + 4, 897 | y: 13, 898 | width, 899 | height: this.viewPort.height, 900 | fill: `rgb(${r},${g},${b})`, 901 | 'font-size': 12, 902 | fill: '#000', 903 | }, node => { node.textContent = category.label })); 904 | } 905 | 906 | this.viewPort.flushFragment(); 907 | } 908 | } 909 | 910 | export class OverView extends SVGWidget { 911 | 912 | constructor(container, profileData) { 913 | super(container, profileData); 914 | 915 | this.viewTimeRange = new ViewTimeRange( 916 | this.profileData.getTimeRange(), 917 | this.profileData.getWallTime(), 918 | this.viewPort.width 919 | ); 920 | 921 | let action = null; 922 | this.container.mouseleave(e => { 923 | action = null; 924 | }); 925 | 926 | this.container.on('mousedown mousemove', e => { 927 | if (e.type == 'mousemove' && e.buttons != 1) { 928 | if (math.dist(e.clientX, this.viewTimeRange.getViewRange().begin) < 4) { 929 | this.container.css('cursor', 'e-resize'); 930 | action = 'move-begin'; 931 | } else if (math.dist(e.clientX, this.viewTimeRange.getViewRange().end) < 4) { 932 | this.container.css('cursor', 'w-resize'); 933 | action = 'move-end'; 934 | } else { 935 | this.container.css('cursor', 'pointer'); 936 | action = 'move'; 937 | } 938 | 939 | return; 940 | } 941 | 942 | switch (action) { 943 | case 'move-begin': 944 | this.viewTimeRange.shiftViewRangeBegin( 945 | e.clientX - this.viewTimeRange.getViewRange().begin 946 | ); 947 | 948 | break; 949 | 950 | case 'move-end': 951 | this.viewTimeRange.shiftViewRangeEnd( 952 | e.clientX - this.viewTimeRange.getViewRange().end 953 | ); 954 | 955 | break; 956 | 957 | case 'move': 958 | this.viewTimeRange.shiftViewRange( 959 | e.clientX - this.viewTimeRange.getViewRange().center() 960 | ); 961 | 962 | break; 963 | } 964 | 965 | this.notifyTimeRangeUpdate(this.viewTimeRange.getTimeRange()); 966 | }); 967 | } 968 | 969 | onTimeRangeUpdate() { 970 | this.viewTimeRange.setTimeRange(this.timeRange.copy()); 971 | if (!this.timeRangeRect) { 972 | return; 973 | } 974 | 975 | const viewRange = this.viewTimeRange.getViewRange(); 976 | 977 | this.timeRangeRect.setAttribute('x', viewRange.begin); 978 | this.timeRangeRect.setAttribute('width', viewRange.length()); 979 | } 980 | 981 | onContainerResize() { 982 | super.onContainerResize(); 983 | this.viewTimeRange.setViewWidth(this.container.width()); 984 | } 985 | 986 | render() { 987 | this.viewPort.appendChildToFragment(svg.createNode('rect', { 988 | x: 0, 989 | y: 0, 990 | width: this.viewPort.width, 991 | height: this.viewPort.height, 992 | 'fill-opacity': '0.3', 993 | })); 994 | 995 | const calls = this.profileData.getCalls( 996 | this.profileData.getTimeRange(), 997 | this.profileData.getTimeRange().length() / this.viewPort.width 998 | ); 999 | 1000 | for (let i = 0; i < calls.length; i++) { 1001 | const call = calls[i]; 1002 | 1003 | const x = this.viewPort.width * call.getStart('wt') / this.profileData.getWallTime(); 1004 | const w = this.viewPort.width * call.getInc('wt') / this.profileData.getWallTime() - 1; 1005 | 1006 | if (w < 0.3) { 1007 | continue; 1008 | } 1009 | 1010 | const h = 1; 1011 | const y = call.getDepth(); 1012 | 1013 | this.viewPort.appendChildToFragment(svg.createNode('line', { 1014 | x1: x, 1015 | y1: y, 1016 | x2: x + w, 1017 | y2: y + h, 1018 | stroke: this.functionColorResolver( 1019 | call.getFunctionName(), 1020 | getCallMetricValueColor( 1021 | this.profileData, 1022 | this.currentMetric, 1023 | call.getInc(this.currentMetric) 1024 | ) 1025 | ), 1026 | })); 1027 | } 1028 | 1029 | renderSVGTimeGrid( 1030 | this.viewPort, 1031 | this.profileData.getTimeRange() 1032 | ); 1033 | 1034 | if (this.currentMetric != 'wt') { 1035 | renderSVGMetricValuesPlot( 1036 | this.viewPort, 1037 | this.profileData, 1038 | this.currentMetric, 1039 | this.profileData.getTimeRange() 1040 | ); 1041 | } 1042 | 1043 | const viewRange = this.viewTimeRange.getViewRange(); 1044 | 1045 | this.timeRangeRect = svg.createNode('rect', { 1046 | x: viewRange.begin, 1047 | y: 0, 1048 | width: viewRange.length(), 1049 | height: this.viewPort.height, 1050 | stroke: new math.Vec3(0, 0.7, 0).toHTMLColor(), 1051 | 'stroke-width': 2, 1052 | fill: new math.Vec3(0, 1, 0).toHTMLColor(), 1053 | 'fill-opacity': '0.1', 1054 | }); 1055 | 1056 | this.viewPort.appendChildToFragment(this.timeRangeRect); 1057 | this.viewPort.flushFragment(); 1058 | } 1059 | } 1060 | 1061 | export class TimeLine extends SVGWidget { 1062 | 1063 | constructor(container, profileData) { 1064 | super(container, profileData); 1065 | 1066 | this.viewTimeRange = new ViewTimeRange( 1067 | this.profileData.getTimeRange(), 1068 | this.profileData.getWallTime(), 1069 | this.viewPort.width 1070 | ); 1071 | 1072 | this.offsetY = 0; 1073 | 1074 | this.svgRectPool = new svg.NodePool('rect'); 1075 | this.svgTextPool = new svg.NodePool('text'); 1076 | 1077 | this.container.bind('wheel', e => { 1078 | if (e.originalEvent.deltaY == 0) { 1079 | return; 1080 | } 1081 | 1082 | e.preventDefault(); 1083 | let f = 1.5; 1084 | if (e.originalEvent.deltaY < 0) { 1085 | f = 1 / f; 1086 | } 1087 | 1088 | this.viewTimeRange.zoomScaledViewRange(f, e.clientX); 1089 | 1090 | this.notifyTimeRangeUpdate(this.viewTimeRange.getTimeRange()); 1091 | }); 1092 | 1093 | this.infoViewPort = null; 1094 | this.selectedCallIdx = null; 1095 | 1096 | const firstPos = {x: 0, y: 0}; 1097 | const lastPos = {x: 0, y: 0}; 1098 | let dragging = false; 1099 | let pointedElement = null, callIdx = null, holdCallInfo = false; 1100 | 1101 | this.container.mousedown(e => { 1102 | dragging = true; 1103 | 1104 | firstPos.x = e.clientX; 1105 | firstPos.y = e.clientY; 1106 | lastPos.x = e.clientX; 1107 | lastPos.y = e.clientY; 1108 | }); 1109 | 1110 | this.container.mouseup(e => { 1111 | dragging = false; 1112 | if ( 1113 | firstPos.x != e.clientX 1114 | || firstPos.y != e.clientY 1115 | ) { 1116 | return; 1117 | } 1118 | 1119 | $(window).trigger( 1120 | 'spx-highlighted-function-update', 1121 | [ 1122 | callIdx != null ? 1123 | this.profileData.getCall(callIdx).getFunctionName() 1124 | : null 1125 | ] 1126 | ); 1127 | 1128 | this.selectedCallIdx = callIdx; 1129 | }); 1130 | 1131 | this.container.mouseleave(e => { 1132 | dragging = false; 1133 | }); 1134 | 1135 | this.container.mousemove(e => { 1136 | if (e.buttons == 0) { 1137 | dragging = false; 1138 | } 1139 | 1140 | if (!dragging) { 1141 | return; 1142 | } 1143 | 1144 | const delta = { 1145 | x: e.clientX - lastPos.x, 1146 | y: e.clientY - lastPos.y, 1147 | }; 1148 | 1149 | lastPos.x = e.clientX; 1150 | lastPos.y = e.clientY; 1151 | 1152 | switch (e.buttons) { 1153 | case 1: 1154 | this.viewTimeRange.shiftScaledViewRange(-delta.x); 1155 | 1156 | this.offsetY += delta.y; 1157 | this.offsetY = Math.min(0, this.offsetY); 1158 | 1159 | break; 1160 | 1161 | case 4: 1162 | let f = Math.pow(1.01, Math.abs(delta.y)); 1163 | if (delta.y < 0) { 1164 | f = 1 / f; 1165 | } 1166 | 1167 | this.viewTimeRange.zoomScaledViewRange(f, e.clientX); 1168 | 1169 | break; 1170 | 1171 | default: 1172 | return; 1173 | } 1174 | 1175 | this.notifyTimeRangeUpdate(this.viewTimeRange.getTimeRange()); 1176 | }); 1177 | 1178 | $(this.viewPort.node).dblclick(e => { 1179 | if (callIdx == null) { 1180 | return; 1181 | } 1182 | 1183 | this.notifyTimeRangeUpdate(this.profileData.getCall(callIdx).getTimeRange()); 1184 | }); 1185 | 1186 | $(this.viewPort.node).on('mousemove mouseout', e => { 1187 | if (this.infoViewPort == null) { 1188 | return; 1189 | } 1190 | 1191 | if (pointedElement != null) { 1192 | if (this.selectedCallIdx == null) { 1193 | pointedElement.setAttribute('stroke', 'none'); 1194 | } 1195 | 1196 | pointedElement = null; 1197 | callIdx = null; 1198 | } 1199 | 1200 | if (this.selectedCallIdx == null) { 1201 | this.infoViewPort.clear(); 1202 | } 1203 | 1204 | if (e.type == 'mouseout') { 1205 | return; 1206 | } 1207 | 1208 | pointedElement = document.elementFromPoint(e.clientX, e.clientY); 1209 | if (pointedElement.nodeName == 'text') { 1210 | pointedElement = pointedElement.previousSibling; 1211 | } 1212 | 1213 | callIdx = pointedElement.dataset.callIdx; 1214 | if (callIdx === undefined) { 1215 | callIdx = null; 1216 | pointedElement = null; 1217 | 1218 | return; 1219 | } 1220 | 1221 | if (this.selectedCallIdx != null) { 1222 | return; 1223 | } 1224 | 1225 | pointedElement.setAttribute('stroke', '#0ff'); 1226 | 1227 | this._renderCallInfo(callIdx); 1228 | }); 1229 | } 1230 | 1231 | onTimeRangeUpdate() { 1232 | this.viewTimeRange.setTimeRange(this.timeRange.copy()); 1233 | this.repaint(); 1234 | } 1235 | 1236 | onContainerResize() { 1237 | super.onContainerResize(); 1238 | this.viewTimeRange.setViewWidth(this.container.width()); 1239 | } 1240 | 1241 | onHighlightedFunctionUpdate() { 1242 | this.selectedCallIdx = null; 1243 | super.onHighlightedFunctionUpdate(); 1244 | } 1245 | 1246 | render() { 1247 | this.viewPort.appendChildToFragment(svg.createNode('rect', { 1248 | x: 0, 1249 | y: 0, 1250 | width: this.viewPort.width, 1251 | height: this.viewPort.height, 1252 | 'fill-opacity': '0.1', 1253 | })); 1254 | 1255 | const timeRange = this.viewTimeRange.getTimeRange(); 1256 | const calls = this.profileData.getCalls( 1257 | timeRange, 1258 | timeRange.length() / this.viewPort.width 1259 | ); 1260 | 1261 | const viewRange = this.viewTimeRange.getScaledViewRange(); 1262 | const offsetX = -viewRange.begin; 1263 | 1264 | this.svgRectPool.releaseAll(); 1265 | this.svgTextPool.releaseAll(); 1266 | 1267 | for (let i = 0; i < calls.length; i++) { 1268 | const call = calls[i]; 1269 | 1270 | let x = offsetX + this.viewPort.width * call.getStart('wt') / timeRange.length(); 1271 | if (x > this.viewPort.width) { 1272 | continue; 1273 | } 1274 | 1275 | let w = this.viewPort.width * call.getInc('wt') / timeRange.length() - 1; 1276 | if (w < 0.1 || x + w < 0) { 1277 | continue; 1278 | } 1279 | 1280 | w = x < 0 ? w + x : w; 1281 | x = x < 0 ? 0 : x; 1282 | w = Math.min(w, this.viewPort.width - x); 1283 | 1284 | const h = 12; 1285 | const y = (h + 1) * call.getDepth() + this.offsetY; 1286 | if (y + h < 0 || y > this.viewPort.height) { 1287 | continue; 1288 | } 1289 | 1290 | const rect = this.svgRectPool.acquire({ 1291 | x: x, 1292 | y: y, 1293 | width: w, 1294 | height: h, 1295 | stroke: call.getIdx() == this.selectedCallIdx ? '#0ff' : 'none', 1296 | 'stroke-width': 2, 1297 | fill: this.functionColorResolver( 1298 | call.getFunctionName(), 1299 | getCallMetricValueColor( 1300 | this.profileData, 1301 | this.currentMetric, 1302 | call.getInc(this.currentMetric) 1303 | ) 1304 | ), 1305 | 'data-call-idx': call.getIdx(), 1306 | }); 1307 | 1308 | this.viewPort.appendChildToFragment(rect); 1309 | 1310 | if (w > 20) { 1311 | const text = this.svgTextPool.acquire({ 1312 | x: x + 2, 1313 | y: y + (h * 0.75), 1314 | width: w, 1315 | height: h, 1316 | 'font-size': h - 2, 1317 | }); 1318 | 1319 | text.textContent = utils.truncateFunctionName(call.getFunctionName(), w / 7); 1320 | this.viewPort.appendChildToFragment(text); 1321 | } 1322 | } 1323 | 1324 | renderSVGTimeGrid( 1325 | this.viewPort, 1326 | timeRange, 1327 | true 1328 | ); 1329 | 1330 | this.viewPort.flushFragment(); 1331 | 1332 | const overlayHeight = 100; 1333 | const overlayViewPort = this.viewPort.createSubViewPort( 1334 | this.viewPort.width, 1335 | overlayHeight, 1336 | 0, 1337 | this.viewPort.height - overlayHeight 1338 | ); 1339 | 1340 | overlayViewPort.appendChildToFragment(svg.createNode('rect', { 1341 | x: 0, 1342 | y: 0, 1343 | width: overlayViewPort.width, 1344 | height: overlayViewPort.height, 1345 | 'fill-opacity': '0.5', 1346 | })); 1347 | 1348 | if (this.currentMetric != 'wt') { 1349 | renderSVGMetricValuesPlot( 1350 | overlayViewPort, 1351 | this.profileData, 1352 | this.currentMetric, 1353 | timeRange 1354 | ); 1355 | } 1356 | 1357 | overlayViewPort.flushFragment(); 1358 | 1359 | this.infoViewPort = overlayViewPort.createSubViewPort( 1360 | overlayViewPort.width, 1361 | 65, 1362 | 0, 1363 | 0 1364 | ); 1365 | 1366 | $(this.infoViewPort.node).css('cursor', 'text'); 1367 | $(this.infoViewPort.node).css('user-select', 'text'); 1368 | $(this.infoViewPort.node).on('mousedown mousemove', e => { 1369 | e.stopPropagation(); 1370 | }); 1371 | 1372 | if (this.selectedCallIdx != null) { 1373 | this._renderCallInfo(this.selectedCallIdx); 1374 | } 1375 | } 1376 | 1377 | _renderCallInfo(callIdx) { 1378 | const call = this.profileData.getCall(callIdx); 1379 | const currentMetricName = this.profileData.getMetricInfo(this.currentMetric).name; 1380 | const formatter = this.profileData.getMetricFormatter(this.currentMetric); 1381 | 1382 | renderSVGMultiLineText( 1383 | this.infoViewPort.createSubViewPort( 1384 | this.infoViewPort.width - 5, 1385 | this.infoViewPort.height, 1386 | 5, 1387 | 0 1388 | ), 1389 | [ 1390 | 'Function: ' + call.getFunctionName(), 1391 | 'Depth: ' + call.getDepth(), 1392 | currentMetricName + ' inc.: ' + formatter(call.getInc(this.currentMetric)), 1393 | currentMetricName + ' exc.: ' + formatter(call.getExc(this.currentMetric)), 1394 | ] 1395 | ); 1396 | } 1397 | } 1398 | 1399 | export class FlameGraph extends SVGWidget { 1400 | 1401 | constructor(container, profileData) { 1402 | super(container, profileData); 1403 | 1404 | this.svgRectPool = new svg.NodePool('rect'); 1405 | this.svgTextPool = new svg.NodePool('text'); 1406 | 1407 | this.pointedElement = null; 1408 | this.renderedCgNodes = []; 1409 | this.infoViewPort = null; 1410 | 1411 | this.viewPort.node.addEventListener('mouseout', e => { 1412 | if (this.pointedElement != null) { 1413 | this.pointedElement.setAttribute('stroke', 'none'); 1414 | this.pointedElement = null; 1415 | } 1416 | 1417 | this.infoViewPort.clear(); 1418 | }); 1419 | 1420 | this.viewPort.node.addEventListener('mousemove', e => { 1421 | if (this.pointedElement != null) { 1422 | this.pointedElement.setAttribute('stroke', 'none'); 1423 | this.pointedElement = null; 1424 | } 1425 | 1426 | this.infoViewPort.clear(); 1427 | 1428 | this.pointedElement = document.elementFromPoint(e.clientX, e.clientY); 1429 | if (this.pointedElement.nodeName == 'text') { 1430 | this.pointedElement = this.pointedElement.previousSibling; 1431 | } 1432 | 1433 | const cgNodeIdx = this.pointedElement.dataset.cgNodeIdx; 1434 | if (cgNodeIdx === undefined) { 1435 | this.pointedElement = null; 1436 | 1437 | return; 1438 | } 1439 | 1440 | this.pointedElement.setAttribute('stroke', '#0ff'); 1441 | 1442 | this.infoViewPort.appendChild(svg.createNode('rect', { 1443 | x: 0, 1444 | y: 0, 1445 | width: this.infoViewPort.width, 1446 | height: this.infoViewPort.height, 1447 | 'fill-opacity': '0.5', 1448 | })); 1449 | 1450 | const cgNode = this.renderedCgNodes[cgNodeIdx]; 1451 | const currentMetricName = this.profileData.getMetricInfo(this.currentMetric).name; 1452 | const formatter = this.profileData.getMetricFormatter(this.currentMetric); 1453 | 1454 | renderSVGMultiLineText( 1455 | this.infoViewPort.createSubViewPort( 1456 | this.infoViewPort.width - 5, 1457 | this.infoViewPort.height, 1458 | 5, 1459 | 0 1460 | ), 1461 | [ 1462 | 'Function: ' + cgNode.getFunctionName(), 1463 | 'Depth: ' + cgNode.getDepth(), 1464 | 'Called: ' + cgNode.getCalled(), 1465 | currentMetricName + ' inc.: ' + formatter(cgNode.getInc().getValue(this.currentMetric)), 1466 | ] 1467 | ); 1468 | }); 1469 | 1470 | this.viewPort.node.addEventListener('click', e => { 1471 | $(window).trigger( 1472 | 'spx-highlighted-function-update', 1473 | [ 1474 | this.pointedElement != null ? 1475 | this.renderedCgNodes[this.pointedElement.dataset.cgNodeIdx].getFunctionName() 1476 | : null 1477 | ] 1478 | ); 1479 | }); 1480 | } 1481 | 1482 | onTimeRangeUpdate() { 1483 | this.repaint(); 1484 | } 1485 | 1486 | render() { 1487 | this.viewPort.appendChild(svg.createNode('rect', { 1488 | x: 0, 1489 | y: 0, 1490 | width: this.viewPort.width, 1491 | height: this.viewPort.height, 1492 | 'fill-opacity': '0.1', 1493 | })); 1494 | 1495 | if (this.profileData.isReleasableMetric(this.currentMetric)) { 1496 | this.viewPort.appendChild(svg.createNode('text', { 1497 | x: this.viewPort.width / 4, 1498 | y: this.viewPort.height / 2, 1499 | height: 20, 1500 | 'font-size': 14, 1501 | fill: '#089', 1502 | }, function(node) { 1503 | node.textContent = 'This visualization is not available for this metric.'; 1504 | })); 1505 | 1506 | return; 1507 | } 1508 | 1509 | this.svgRectPool.releaseAll(); 1510 | this.svgTextPool.releaseAll(); 1511 | 1512 | this.renderedCgNodes = []; 1513 | 1514 | const renderNode = (node, maxCumInc, x, y) => { 1515 | x = x || 0; 1516 | y = y || this.viewPort.height; 1517 | 1518 | const w = this.viewPort.width 1519 | * node.getInc().getValue(this.currentMetric) 1520 | / (maxCumInc) 1521 | - 1 1522 | ; 1523 | 1524 | if (w < 0.3) { 1525 | return x; 1526 | } 1527 | 1528 | const h = math.bound(y / (node.getDepth() + 1), 2, 12); 1529 | y -= h + 0.5; 1530 | 1531 | let childrenX = x; 1532 | for (let child of node.getChildren()) { 1533 | childrenX = renderNode(child, maxCumInc, childrenX, y); 1534 | } 1535 | 1536 | this.renderedCgNodes.push(node); 1537 | const nodeIdx = this.renderedCgNodes.length - 1; 1538 | 1539 | this.viewPort.appendChildToFragment(this.svgRectPool.acquire({ 1540 | x: x, 1541 | y: y, 1542 | width: w, 1543 | height: h, 1544 | stroke: 'none', 1545 | 'stroke-width': 2, 1546 | fill: this.functionColorResolver( 1547 | node.getFunctionName(), 1548 | math.Vec3.lerp( 1549 | new math.Vec3(1, 0, 0), 1550 | new math.Vec3(1, 1, 0), 1551 | 0.5 1552 | * Math.min(1, node.getDepth() / 20) 1553 | + 0.5 1554 | * node.getInc().getValue(this.currentMetric) 1555 | / (maxCumInc) 1556 | ).toHTMLColor() 1557 | ), 1558 | 'fill-opacity': '1', 1559 | 'data-cg-node-idx': nodeIdx, 1560 | })); 1561 | 1562 | if (w > 20 && h > 5) { 1563 | const text = this.svgTextPool.acquire({ 1564 | x: x + 2, 1565 | y: y + (h * 0.75), 1566 | width: w, 1567 | height: h, 1568 | 'font-size': h - 2, 1569 | }); 1570 | 1571 | text.textContent = utils.truncateFunctionName(node.getFunctionName(), w / 7); 1572 | this.viewPort.appendChildToFragment(text); 1573 | } 1574 | 1575 | return Math.max(x + w, childrenX); 1576 | }; 1577 | 1578 | const cgRoot = this 1579 | .timeRangeStats 1580 | .getCallTreeStats(this.timeRange) 1581 | .getRoot() 1582 | ; 1583 | 1584 | const maxCumInc = cgRoot.getMaxCumInc().getValue(this.currentMetric); 1585 | 1586 | let x = 0; 1587 | for (const child of cgRoot.getChildren()) { 1588 | x = renderNode(child, maxCumInc, x); 1589 | } 1590 | 1591 | this.viewPort.flushFragment(); 1592 | 1593 | this.pointedElement = null; 1594 | 1595 | this.infoViewPort = this.viewPort.createSubViewPort( 1596 | this.viewPort.width, 1597 | 65, 1598 | 0, 1599 | 0 1600 | ); 1601 | }; 1602 | } 1603 | 1604 | export class FlatProfile extends Widget { 1605 | 1606 | constructor(container, profileData) { 1607 | super(container, profileData); 1608 | 1609 | this.sortCol = 'exc'; 1610 | this.sortDir = -1; 1611 | } 1612 | 1613 | onTimeRangeUpdate() { 1614 | this.repaint(); 1615 | } 1616 | 1617 | render() { 1618 | 1619 | let html = ` 1620 | 1621 | 1622 | 1623 | 1624 | 1625 | 1626 | 1627 | 1628 | 1629 | 1630 | 1631 | 1632 | 1633 | 1634 | 1635 | 1636 | 1637 | 1638 |
    FunctionCalled${this.profileData.getMetricInfo(this.currentMetric).name}
    PercentageValue
    Inc.Exc.Inc.Exc.
    1639 | `; 1640 | 1641 | html += ` 1642 |
    1643 | 1644 | `; 1645 | 1646 | const functionsStats = this.timeRangeStats.getFunctionsStats().getValues(); 1647 | 1648 | functionsStats.sort((a, b) => { 1649 | switch (this.sortCol) { 1650 | case 'name': 1651 | a = a.functionName; 1652 | b = b.functionName; 1653 | 1654 | break; 1655 | 1656 | case 'called': 1657 | a = a.called; 1658 | b = b.called; 1659 | 1660 | break; 1661 | 1662 | case 'inc_rel': 1663 | case 'inc': 1664 | a = a.inc.getValue(this.currentMetric); 1665 | b = b.inc.getValue(this.currentMetric); 1666 | 1667 | break; 1668 | 1669 | case 'exc_rel': 1670 | case 'exc': 1671 | default: 1672 | a = a.exc.getValue(this.currentMetric); 1673 | b = b.exc.getValue(this.currentMetric); 1674 | } 1675 | 1676 | return (a < b ? -1 : (a > b)) * this.sortDir; 1677 | }); 1678 | 1679 | const formatter = this.profileData.getMetricFormatter(this.currentMetric); 1680 | const limit = Math.min(100, functionsStats.length); 1681 | 1682 | const cumCostStats = this.timeRangeStats.getCumCostStats(); 1683 | 1684 | const renderRelativeCostBar = (value) => { 1685 | if (this.profileData.isReleasableMetric(this.currentMetric)) { 1686 | return ` 1687 |
    1688 |
    1689 |
    1690 |
    1691 |
    1692 |
    1693 | `; 1694 | } 1695 | 1696 | return ` 1697 |
    1698 |
    1699 |
    1700 | `; 1701 | }; 1702 | 1703 | for (let i = 0; i < limit; i++) { 1704 | const stats = functionsStats[i]; 1705 | 1706 | const neg = stats.inc.getValue(this.currentMetric) < 0 ? 1 : 0; 1707 | const relRange = neg ? 1708 | cumCostStats.getNegRange(this.currentMetric) : cumCostStats.getPosRange(this.currentMetric); 1709 | 1710 | const inc = stats.inc.getValue(this.currentMetric); 1711 | const incRel = -1 * neg + relRange.lerpDist( 1712 | stats.inc.getValue(this.currentMetric) 1713 | ); 1714 | 1715 | const exc = stats.exc.getValue(this.currentMetric); 1716 | const excRel = -1 * neg + relRange.lerpDist( 1717 | stats.exc.getValue(this.currentMetric) 1718 | ); 1719 | 1720 | let functionLabel = stats.functionName; 1721 | if (stats.maxCycleDepth > 0) { 1722 | functionLabel += '@' + stats.maxCycleDepth; 1723 | } 1724 | 1725 | html += ` 1726 | 1727 | 1743 | 1744 | 1745 | 1746 | 1747 | 1748 | 1749 | `; 1750 | } 1751 | 1752 | html += '
    1741 | ${utils.truncateFunctionName(functionLabel, (this.container.width() - 5 * 90) / 8)} 1742 | ${fmt.quantity(stats.called)}${fmt.pct(incRel)}${renderRelativeCostBar(incRel)}${fmt.pct(excRel)}${renderRelativeCostBar(excRel)}${formatter(inc)}${formatter(exc)}
    '; 1753 | 1754 | this.container.append(html); 1755 | 1756 | this.container.find('th[data-sort="' + this.sortCol + '"]').addClass('sort'); 1757 | 1758 | this.container.find('th').click(e => { 1759 | let sortCol = $(e.target).data('sort'); 1760 | if (!sortCol) { 1761 | return; 1762 | } 1763 | 1764 | if (this.sortCol == sortCol) { 1765 | this.sortDir *= -1; 1766 | } 1767 | 1768 | this.sortCol = sortCol; 1769 | this.repaint(); 1770 | }); 1771 | 1772 | this.container.find('tbody td').click(e => { 1773 | const functionName = $(e.target).data('function-name'); 1774 | 1775 | $(window).trigger( 1776 | 'spx-highlighted-function-update', 1777 | [ 1778 | functionName != undefined ? functionName : null 1779 | ] 1780 | ); 1781 | }); 1782 | } 1783 | } 1784 | --------------------------------------------------------------------------------