diff --git a/.env b/.env index 2c9e740c8b..227ea10676 100644 --- a/.env +++ b/.env @@ -76,7 +76,8 @@ _APP_MAINTENANCE_RETENTION_CACHE=2592000 _APP_MAINTENANCE_RETENTION_EXECUTION=1209600 _APP_MAINTENANCE_RETENTION_ABUSE=86400 _APP_MAINTENANCE_RETENTION_AUDIT=1209600 -_APP_USAGE_AGGREGATION_INTERVAL=30 +_APP_USAGE_TIMESERIES_INTERVAL=2 +_APP_USAGE_DATABASE_INTERVAL=15 _APP_USAGE_STATS=enabled _APP_LOGGING_PROVIDER= _APP_LOGGING_CONFIG= diff --git a/Dockerfile b/Dockerfile index 4930bdcb87..7d583aad27 100755 --- a/Dockerfile +++ b/Dockerfile @@ -234,6 +234,8 @@ ENV _APP_SERVER=swoole \ _APP_SETUP=self-hosted \ _APP_VERSION=$VERSION \ _APP_USAGE_STATS=enabled \ + _APP_USAGE_TIMESERIES_INTERVAL=30 \ + _APP_USAGE_DATABASE_INTERVAL=900 \ # 14 Days = 1209600 s _APP_MAINTENANCE_RETENTION_EXECUTION=1209600 \ _APP_MAINTENANCE_RETENTION_AUDIT=1209600 \ diff --git a/app/config/variables.php b/app/config/variables.php index 9d347cd1cf..9ca42c3e87 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -170,13 +170,31 @@ return [ ], [ 'name' => '_APP_USAGE_AGGREGATION_INTERVAL', - 'description' => 'Interval value containing the number of seconds that the Appwrite usage process should wait before aggregating stats and syncing it to mariadb from InfluxDB. The default value is 30 seconds.', + 'description' => 'Deprecated since 0.16.0, use `_APP_USAGE_TIMESERIES_INTERVAL` and `_APP_USAGE_DATABASE_INTERVAL` instead.', 'introduction' => '0.10.0', 'default' => '30', 'required' => false, 'question' => '', 'filter' => '' ], + [ + 'name' => '_APP_USAGE_TIMESERIES_INTERVAL', + 'description' => 'Interval value containing the number of seconds that the Appwrite usage process should wait before aggregating stats and syncing it to Appwrite Database from Timeseries Database. The default value is 30 seconds.', + 'introduction' => '0.16.0', + 'default' => '30', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_USAGE_DATABASE_INTERVAL', + 'description' => 'Interval value containing the number of seconds that the Appwrite usage process should wait before aggregating stats from data in Appwrite Database. The default value is 15 minutes.', + 'introduction' => '0.16.0', + 'default' => '900', + 'required' => false, + 'question' => '', + 'filter' => '' + ], [ 'name' => '_APP_WORKER_PER_CORE', 'description' => 'Internal Worker per core for the API, Realtime and Executor containers. Can be configured to optimize performance.', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 8d5fe517d4..1d93d8de55 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -15,7 +15,6 @@ use Appwrite\Network\Validator\Email; use Appwrite\Network\Validator\Host; use Appwrite\Network\Validator\URL; use Appwrite\OpenSSL\OpenSSL; -use Appwrite\Stats\Stats; use Appwrite\Template\Template; use Appwrite\URL\URL as URLParser; use Appwrite\Utopia\Database\Validator\CustomId; @@ -56,6 +55,7 @@ App::post('/v1/account') ->label('auth.type', 'emailPassword') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.create') ->label('sdk.auth', []) ->label('sdk.namespace', 'account') ->label('sdk.method', 'create') @@ -72,9 +72,8 @@ App::post('/v1/account') ->inject('response') ->inject('project') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $project, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $project, Database $dbForProject, Event $events) { $email = \strtolower($email); if ('console' === $project->getId()) { @@ -133,7 +132,6 @@ App::post('/v1/account') Authorization::setRole(Role::user($user->getId())->toString()); Authorization::setRole(Role::users()->toString()); - $usage->setParam('users.create', 1); $events->setParam('userId', $user->getId()); $response->setStatusCode(Response::STATUS_CODE_CREATED); @@ -149,6 +147,8 @@ App::post('/v1/account/sessions/email') ->label('auth.type', 'emailPassword') ->label('audits.resource', 'user/{response.userId}') ->label('audits.userId', '{response.userId}') + ->label('usage.metric', 'sessions.{scope}.requests.create') + ->label('usage.params', ['provider:email']) ->label('sdk.auth', []) ->label('sdk.namespace', 'account') ->label('sdk.method', 'createEmailSession') @@ -165,9 +165,8 @@ App::post('/v1/account/sessions/email') ->inject('dbForProject') ->inject('locale') ->inject('geodb') - ->inject('usage') ->inject('events') - ->action(function (string $email, string $password, Request $request, Response $response, Database $dbForProject, Locale $locale, Reader $geodb, Stats $usage, Event $events) { + ->action(function (string $email, string $password, Request $request, Response $response, Database $dbForProject, Locale $locale, Reader $geodb, Event $events) { $email = \strtolower($email); $protocol = $request->getProtocol(); @@ -245,12 +244,6 @@ App::post('/v1/account/sessions/email') ->setAttribute('countryName', $countryName) ; - $usage - ->setParam('users.update', 1) - ->setParam('users.sessions.create', 1) - ->setParam('provider', 'email') - ; - $events ->setParam('userId', $profile->getId()) ->setParam('sessionId', $session->getId()) @@ -377,6 +370,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ->label('abuse-limit', 50) ->label('abuse-key', 'ip:{ip}') ->label('docs', false) + ->label('usage.metric', 'sessions.{scope}.requests.create') + ->label('usage.params', ['provider:{request.provider}']) ->param('provider', '', new WhiteList(\array_keys(Config::getParam('providers')), true), 'OAuth2 provider.') ->param('code', '', new Text(2048), 'OAuth2 code.') ->param('state', '', new Text(2048), 'OAuth2 state params.', true) @@ -387,8 +382,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('dbForProject') ->inject('geodb') ->inject('events') - ->inject('usage') - ->action(function (string $provider, string $code, string $state, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Reader $geodb, Event $events, Stats $usage) use ($oauthDefaultSuccess) { + ->action(function (string $provider, string $code, string $state, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Reader $geodb, Event $events) use ($oauthDefaultSuccess) { $protocol = $request->getProtocol(); $callback = $protocol . '://' . $request->getHostname() . '/v1/account/sessions/oauth2/callback/' . $provider . '/' . $project->getId(); @@ -574,12 +568,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $dbForProject->deleteCachedDocument('users', $user->getId()); - $usage - ->setParam('users.sessions.create', 1) - ->setParam('projectId', $project->getId()) - ->setParam('provider', 'oauth2-' . $provider) - ; - $events ->setParam('userId', $user->getId()) ->setParam('sessionId', $session->getId()) @@ -750,6 +738,8 @@ App::put('/v1/account/sessions/magic-url') ->label('event', 'users.[userId].sessions.[sessionId].create') ->label('audits.resource', 'user/{response.userId}') ->label('audits.userId', '{response.userId}') + ->label('usage.metric', 'sessions.{scope}.requests.create') + ->label('usage.params', ['provider:magic-url']) ->label('sdk.auth', []) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updateMagicURLSession') @@ -984,6 +974,8 @@ App::put('/v1/account/sessions/phone') ->groups(['api', 'account']) ->label('scope', 'public') ->label('event', 'users.[userId].sessions.[sessionId].create') + ->label('usage.metric', 'sessions.{scope}.requests.create') + ->label('usage.params', ['provider:phone']) ->label('sdk.auth', []) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updatePhoneSession') @@ -1098,6 +1090,8 @@ App::post('/v1/account/sessions/anonymous') ->label('auth.type', 'anonymous') ->label('audits.resource', 'user/{response.userId}') ->label('audits.userId', '{response.userId}') + ->label('usage.metric', 'sessions.{scope}.requests.create') + ->label('usage.params', ['provider:anonymous']) ->label('sdk.auth', []) ->label('sdk.namespace', 'account') ->label('sdk.method', 'createAnonymousSession') @@ -1114,9 +1108,8 @@ App::post('/v1/account/sessions/anonymous') ->inject('project') ->inject('dbForProject') ->inject('geodb') - ->inject('usage') ->inject('events') - ->action(function (Request $request, Response $response, Locale $locale, Document $user, Document $project, Database $dbForProject, Reader $geodb, Stats $usage, Event $events) { + ->action(function (Request $request, Response $response, Locale $locale, Document $user, Document $project, Database $dbForProject, Reader $geodb, Event $events) { $protocol = $request->getProtocol(); @@ -1197,11 +1190,6 @@ App::post('/v1/account/sessions/anonymous') $dbForProject->deleteCachedDocument('users', $user->getId()); - $usage - ->setParam('users.sessions.create', 1) - ->setParam('provider', 'anonymous') - ; - $events ->setParam('userId', $user->getId()) ->setParam('sessionId', $session->getId()) @@ -1277,6 +1265,7 @@ App::get('/v1/account') ->desc('Get Account') ->groups(['api', 'account']) ->label('scope', 'account') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'get') @@ -1286,9 +1275,8 @@ App::get('/v1/account') ->label('sdk.response.model', Response::MODEL_ACCOUNT) ->inject('response') ->inject('user') - ->inject('usage') - ->action(function (Response $response, Document $user, Stats $usage) { - $usage->setParam('users.read', 1); + ->action(function (Response $response, Document $user) { + $response->dynamic($user, Response::MODEL_ACCOUNT); }); @@ -1296,6 +1284,7 @@ App::get('/v1/account/prefs') ->desc('Get Account Preferences') ->groups(['api', 'account']) ->label('scope', 'account') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'getPrefs') @@ -1305,13 +1294,10 @@ App::get('/v1/account/prefs') ->label('sdk.response.model', Response::MODEL_PREFERENCES) ->inject('response') ->inject('user') - ->inject('usage') - ->action(function (Response $response, Document $user, Stats $usage) { + ->action(function (Response $response, Document $user) { $prefs = $user->getAttribute('prefs', new \stdClass()); - $usage->setParam('users.read', 1); - $response->dynamic(new Document($prefs), Response::MODEL_PREFERENCES); }); @@ -1319,6 +1305,7 @@ App::get('/v1/account/sessions') ->desc('Get Account Sessions') ->groups(['api', 'account']) ->label('scope', 'account') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'getSessions') @@ -1329,8 +1316,7 @@ App::get('/v1/account/sessions') ->inject('response') ->inject('user') ->inject('locale') - ->inject('usage') - ->action(function (Response $response, Document $user, Locale $locale, Stats $usage) { + ->action(function (Response $response, Document $user, Locale $locale) { $sessions = $user->getAttribute('sessions', []); $current = Auth::sessionVerify($sessions, Auth::$secret); @@ -1344,8 +1330,6 @@ App::get('/v1/account/sessions') $sessions[$key] = $session; } - $usage->setParam('users.read', 1); - $response->dynamic(new Document([ 'sessions' => $sessions, 'total' => count($sessions), @@ -1356,6 +1340,7 @@ App::get('/v1/account/logs') ->desc('Get Account Logs') ->groups(['api', 'account']) ->label('scope', 'account') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'getLogs') @@ -1407,8 +1392,6 @@ App::get('/v1/account/logs') } } - $usage->setParam('users.read', 1); - $response->dynamic(new Document([ 'total' => $audit->countLogsByUser($user->getId()), 'logs' => $output, @@ -1419,6 +1402,7 @@ App::get('/v1/account/sessions/:sessionId') ->desc('Get Session By ID') ->groups(['api', 'account']) ->label('scope', 'account') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'getSession') @@ -1431,8 +1415,7 @@ App::get('/v1/account/sessions/:sessionId') ->inject('user') ->inject('locale') ->inject('dbForProject') - ->inject('usage') - ->action(function (?string $sessionId, Response $response, Document $user, Locale $locale, Database $dbForProject, Stats $usage) { + ->action(function (?string $sessionId, Response $response, Document $user, Locale $locale, Database $dbForProject) { $sessions = $user->getAttribute('sessions', []); $sessionId = ($sessionId === 'current') @@ -1448,8 +1431,6 @@ App::get('/v1/account/sessions/:sessionId') ->setAttribute('countryName', $countryName) ; - $usage->setParam('users.read', 1); - return $response->dynamic($session, Response::MODEL_SESSION); } } @@ -1463,6 +1444,7 @@ App::patch('/v1/account/name') ->label('event', 'users.[userId].update.name') ->label('scope', 'account') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updateName') @@ -1474,15 +1456,13 @@ App::patch('/v1/account/name') ->inject('response') ->inject('user') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $name, Response $response, Document $user, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $name, Response $response, Document $user, Database $dbForProject, Event $events) { $user = $dbForProject->updateDocument('users', $user->getId(), $user ->setAttribute('name', $name) ->setAttribute('search', implode(' ', [$user->getId(), $name, $user->getAttribute('email', ''), $user->getAttribute('phone', '')]))); - $usage->setParam('users.update', 1); $events->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_ACCOUNT); @@ -1495,6 +1475,7 @@ App::patch('/v1/account/password') ->label('scope', 'account') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updatePassword') @@ -1507,9 +1488,8 @@ App::patch('/v1/account/password') ->inject('response') ->inject('user') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $password, string $oldPassword, Response $response, Document $user, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $password, string $oldPassword, Response $response, Document $user, Database $dbForProject, Event $events) { // Check old password only if its an existing user. if ($user->getAttribute('passwordUpdate') !== null && !Auth::passwordVerify($oldPassword, $user->getAttribute('password'), $user->getAttribute('hash'), $user->getAttribute('hashOptions'))) { // Double check user password @@ -1522,7 +1502,6 @@ App::patch('/v1/account/password') ->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS) ->setAttribute('passwordUpdate', DateTime::now())); - $usage->setParam('users.update', 1); $events->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_ACCOUNT); @@ -1534,6 +1513,7 @@ App::patch('/v1/account/email') ->label('event', 'users.[userId].update.email') ->label('scope', 'account') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updateEmail') @@ -1546,9 +1526,8 @@ App::patch('/v1/account/email') ->inject('response') ->inject('user') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $email, string $password, Response $response, Document $user, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $email, string $password, Response $response, Document $user, Database $dbForProject, Event $events) { $isAnonymousUser = Auth::isAnonymousUser($user); // Check if request is from an anonymous account for converting if ( @@ -1574,7 +1553,6 @@ App::patch('/v1/account/email') throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); } - $usage->setParam('users.update', 1); $events->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_ACCOUNT); @@ -1586,6 +1564,7 @@ App::patch('/v1/account/phone') ->label('event', 'users.[userId].update.phone') ->label('scope', 'account') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updatePhone') @@ -1598,9 +1577,8 @@ App::patch('/v1/account/phone') ->inject('response') ->inject('user') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $phone, string $password, Response $response, Document $user, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $phone, string $password, Response $response, Document $user, Database $dbForProject, Event $events) { $isAnonymousUser = Auth::isAnonymousUser($user); // Check if request is from an anonymous account for converting @@ -1622,7 +1600,6 @@ App::patch('/v1/account/phone') throw new Exception(Exception::USER_PHONE_ALREADY_EXISTS); } - $usage->setParam('users.update', 1); $events->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_ACCOUNT); @@ -1634,6 +1611,7 @@ App::patch('/v1/account/prefs') ->label('event', 'users.[userId].update.prefs') ->label('scope', 'account') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updatePrefs') @@ -1645,13 +1623,11 @@ App::patch('/v1/account/prefs') ->inject('response') ->inject('user') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (array $prefs, Response $response, Document $user, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (array $prefs, Response $response, Document $user, Database $dbForProject, Event $events) { $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('prefs', $prefs)); - $usage->setParam('users.update', 1); $events->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_ACCOUNT); @@ -1663,6 +1639,7 @@ App::patch('/v1/account/status') ->label('event', 'users.[userId].update.status') ->label('scope', 'account') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.delete') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updateStatus') @@ -1675,8 +1652,7 @@ App::patch('/v1/account/status') ->inject('user') ->inject('dbForProject') ->inject('events') - ->inject('usage') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $events, Stats $usage) { + ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $events) { $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('status', false)); @@ -1688,8 +1664,6 @@ App::patch('/v1/account/status') $response->addHeader('X-Fallback-Cookies', \json_encode([])); } - $usage->setParam('users.delete', 1); - $response->dynamic($user, Response::MODEL_ACCOUNT); }); @@ -1699,6 +1673,7 @@ App::delete('/v1/account/sessions/:sessionId') ->label('scope', 'account') ->label('event', 'users.[userId].sessions.[sessionId].delete') ->label('audits.resource', 'user/{user.$id}') + ->label('usage.metric', 'sessions.{scope}.requests.delete') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'deleteSession') @@ -1713,8 +1688,7 @@ App::delete('/v1/account/sessions/:sessionId') ->inject('dbForProject') ->inject('locale') ->inject('events') - ->inject('usage') - ->action(function (?string $sessionId, Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $events, Stats $usage) { + ->action(function (?string $sessionId, Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $events) { $protocol = $request->getProtocol(); $sessionId = ($sessionId === 'current') @@ -1756,11 +1730,6 @@ App::delete('/v1/account/sessions/:sessionId') ->setParam('sessionId', $session->getId()) ->setPayload($response->output($session, Response::MODEL_SESSION)) ; - - $usage - ->setParam('users.sessions.delete', 1) - ->setParam('users.update', 1) - ; return $response->noContent(); } } @@ -1775,6 +1744,7 @@ App::patch('/v1/account/sessions/:sessionId') ->label('event', 'users.[userId].sessions.[sessionId].update') ->label('audits.resource', 'user/{response.userId}') ->label('audits.userId', '{response.userId}') + ->label('usage.metric', 'sessions.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updateSession') @@ -1791,8 +1761,7 @@ App::patch('/v1/account/sessions/:sessionId') ->inject('project') ->inject('locale') ->inject('events') - ->inject('usage') - ->action(function (?string $sessionId, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Event $events, Stats $usage) { + ->action(function (?string $sessionId, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Event $events) { $sessionId = ($sessionId === 'current') ? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret) @@ -1842,11 +1811,6 @@ App::patch('/v1/account/sessions/:sessionId') ->setPayload($response->output($session, Response::MODEL_SESSION)) ; - $usage - ->setParam('users.sessions.update', 1) - ->setParam('users.update', 1) - ; - return $response->dynamic($session, Response::MODEL_SESSION); } } @@ -1860,6 +1824,7 @@ App::delete('/v1/account/sessions') ->label('scope', 'account') ->label('event', 'users.[userId].sessions.[sessionId].delete') ->label('audits.resource', 'user/{user.$id}') + ->label('usage.metric', 'sessions.{scope}.requests.delete') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'deleteSessions') @@ -1873,8 +1838,7 @@ App::delete('/v1/account/sessions') ->inject('dbForProject') ->inject('locale') ->inject('events') - ->inject('usage') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $events, Stats $usage) { + ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $events) { $protocol = $request->getProtocol(); $sessions = $user->getAttribute('sessions', []); @@ -1912,11 +1876,6 @@ App::delete('/v1/account/sessions') ->setParam('userId', $user->getId()) ->setParam('sessionId', $session->getId()); - $usage - ->setParam('users.sessions.delete', $numOfSessions) - ->setParam('users.update', 1) - ; - $response->noContent(); }); @@ -1927,6 +1886,7 @@ App::post('/v1/account/recovery') ->label('event', 'users.[userId].recovery.[tokenId].create') ->label('audits.resource', 'user/{response.userId}') ->label('audits.userId', '{response.userId}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'createRecovery') @@ -1945,8 +1905,7 @@ App::post('/v1/account/recovery') ->inject('locale') ->inject('mails') ->inject('events') - ->inject('usage') - ->action(function (string $email, string $url, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Mail $mails, Event $events, Stats $usage) { + ->action(function (string $email, string $url, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Mail $mails, Event $events) { if (empty(App::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled'); @@ -2021,8 +1980,6 @@ App::post('/v1/account/recovery') // Hide secret for clients $recovery->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $secret : ''); - $usage->setParam('users.update', 1); - $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($recovery, Response::MODEL_TOKEN); }); @@ -2034,6 +1991,7 @@ App::put('/v1/account/recovery') ->label('event', 'users.[userId].recovery.[tokenId].update') ->label('audits.resource', 'user/{response.userId}') ->label('audits.userId', '{response.userId}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updateRecovery') @@ -2049,9 +2007,8 @@ App::put('/v1/account/recovery') ->param('passwordAgain', '', new Password(), 'Repeat new user password. Must be at least 8 chars.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Database $dbForProject, Event $events) { if ($password !== $passwordAgain) { throw new Exception(Exception::USER_PASSWORD_MISMATCH); } @@ -2087,8 +2044,6 @@ App::put('/v1/account/recovery') $dbForProject->deleteDocument('tokens', $recovery); $dbForProject->deleteCachedDocument('users', $profile->getId()); - $usage->setParam('users.update', 1); - $events ->setParam('userId', $profile->getId()) ->setParam('tokenId', $recoveryDocument->getId()) @@ -2103,6 +2058,7 @@ App::post('/v1/account/verification') ->label('scope', 'account') ->label('event', 'users.[userId].verification.[tokenId].create') ->label('audits.resource', 'user/{response.userId}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'createVerification') @@ -2121,8 +2077,7 @@ App::post('/v1/account/verification') ->inject('locale') ->inject('events') ->inject('mails') - ->inject('usage') - ->action(function (string $url, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Event $events, Mail $mails, Stats $usage) { + ->action(function (string $url, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Event $events, Mail $mails) { if (empty(App::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled'); @@ -2181,8 +2136,6 @@ App::post('/v1/account/verification') // Hide secret for clients $verification->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $verificationSecret : ''); - $usage->setParam('users.update', 1); - $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($verification, Response::MODEL_TOKEN); }); @@ -2193,6 +2146,7 @@ App::put('/v1/account/verification') ->label('scope', 'public') ->label('event', 'users.[userId].verification.[tokenId].update') ->label('audits.resource', 'user/{response.userId}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updateVerification') @@ -2207,9 +2161,8 @@ App::put('/v1/account/verification') ->inject('response') ->inject('user') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Event $events) { $profile = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId)); @@ -2237,8 +2190,6 @@ App::put('/v1/account/verification') $dbForProject->deleteDocument('tokens', $verification); $dbForProject->deleteCachedDocument('users', $profile->getId()); - $usage->setParam('users.update', 1); - $events ->setParam('userId', $user->getId()) ->setParam('tokenId', $verificationDocument->getId()) @@ -2253,6 +2204,7 @@ App::post('/v1/account/verification/phone') ->label('scope', 'account') ->label('event', 'users.[userId].verification.[tokenId].create') ->label('audits.resource', 'user/{response.userId}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'createPhoneVerification') @@ -2267,9 +2219,8 @@ App::post('/v1/account/verification/phone') ->inject('user') ->inject('dbForProject') ->inject('events') - ->inject('usage') ->inject('messaging') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $events, Stats $usage, EventPhone $messaging) { + ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $events, EventPhone $messaging) { if (empty(App::getEnv('_APP_SMS_PROVIDER'))) { throw new Exception(Exception::GENERAL_PHONE_DISABLED); @@ -2326,8 +2277,6 @@ App::post('/v1/account/verification/phone') // Hide secret for clients $verification->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $verificationSecret : ''); - $usage->setParam('users.update', 1); - $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($verification, Response::MODEL_TOKEN); }); @@ -2338,6 +2287,7 @@ App::put('/v1/account/verification/phone') ->label('scope', 'public') ->label('event', 'users.[userId].verification.[tokenId].update') ->label('audits.resource', 'user/{response.userId}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'account') ->label('sdk.method', 'updatePhoneVerification') @@ -2352,9 +2302,8 @@ App::put('/v1/account/verification/phone') ->inject('response') ->inject('user') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Event $events) { $profile = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId)); @@ -2380,8 +2329,6 @@ App::put('/v1/account/verification/phone') $dbForProject->deleteDocument('tokens', $verification); $dbForProject->deleteCachedDocument('users', $profile->getId()); - $usage->setParam('users.update', 1); - $events ->setParam('userId', $user->getId()) ->setParam('tokenId', $verificationDocument->getId()) diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 5b64db341e..884807fcfc 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -1,6 +1,5 @@ getAttribute('key'); $type = $attribute->getAttribute('type', ''); @@ -134,10 +133,6 @@ function createAttribute(string $databaseId, string $collectionId, Document $att $dbForProject->deleteCachedDocument('database_' . $db->getInternalId(), $collectionId); $dbForProject->deleteCachedCollection('database_' . $db->getInternalId() . '_collection_' . $collection->getInternalId()); - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.update', 1); - $database ->setType(DATABASE_TYPE_CREATE_ATTRIBUTE) ->setDatabase($db) @@ -164,6 +159,7 @@ App::post('/v1/databases') ->label('event', 'databases.[databaseId].create') ->label('scope', 'databases.write') ->label('audits.resource', 'database/{response.$id}') + ->label('usage.metric', 'databases.{scope}.requests.create') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'create') @@ -175,9 +171,8 @@ App::post('/v1/databases') ->param('name', '', new Text(128), 'Collection name. Max length: 128 chars.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $name, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $name, Response $response, Database $dbForProject, Event $events) { $databaseId = $databaseId == 'unique()' ? ID::unique() : $databaseId; @@ -226,7 +221,6 @@ App::post('/v1/databases') } $events->setParam('databaseId', $database->getId()); - $usage->setParam('databases.create', 1); $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($database, Response::MODEL_DATABASE); @@ -236,6 +230,7 @@ App::get('/v1/databases') ->desc('List Databases') ->groups(['api', 'database']) ->label('scope', 'databases.read') + ->label('usage.metric', 'databases.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'list') @@ -287,6 +282,7 @@ App::get('/v1/databases/:databaseId') ->desc('Get Database') ->groups(['api', 'database']) ->label('scope', 'databases.read') + ->label('usage.metric', 'databases.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'get') @@ -297,8 +293,7 @@ App::get('/v1/databases/:databaseId') ->param('databaseId', '', new UID(), 'Database ID.') ->inject('response') ->inject('dbForProject') - ->inject('usage') - ->action(function (string $databaseId, Response $response, Database $dbForProject, Stats $usage) { + ->action(function (string $databaseId, Response $response, Database $dbForProject) { $database = $dbForProject->getDocument('databases', $databaseId); @@ -306,8 +301,6 @@ App::get('/v1/databases/:databaseId') throw new Exception(Exception::DATABASE_NOT_FOUND); } - $usage->setParam('databases.read', 1); - $response->dynamic($database, Response::MODEL_DATABASE); }); @@ -403,6 +396,7 @@ App::put('/v1/databases/:databaseId') ->label('scope', 'databases.write') ->label('event', 'databases.[databaseId].update') ->label('audits.resource', 'database/{response.$id}') + ->label('usage.metric', 'databases.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'update') @@ -414,9 +408,8 @@ App::put('/v1/databases/:databaseId') ->param('name', null, new Text(128), 'Collection name. Max length: 128 chars.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $name, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $name, Response $response, Database $dbForProject, Event $events) { $database = $dbForProject->getDocument('databases', $databaseId); @@ -434,7 +427,6 @@ App::put('/v1/databases/:databaseId') throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, 'Bad structure. ' . $exception->getMessage()); } - $usage->setParam('databases.update', 1); $events->setParam('databaseId', $database->getId()); $response->dynamic($database, Response::MODEL_DATABASE); @@ -446,6 +438,7 @@ App::delete('/v1/databases/:databaseId') ->label('scope', 'databases.write') ->label('event', 'databases.[databaseId].delete') ->label('audits.resource', 'database/{request.databaseId}') + ->label('usage.metric', 'databases.{scope}.requests.delete') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'delete') @@ -457,8 +450,7 @@ App::delete('/v1/databases/:databaseId') ->inject('dbForProject') ->inject('events') ->inject('deletes') - ->inject('usage') - ->action(function (string $databaseId, Response $response, Database $dbForProject, Event $events, Delete $deletes, Stats $usage) { + ->action(function (string $databaseId, Response $response, Database $dbForProject, Event $events, Delete $deletes) { $database = $dbForProject->getDocument('databases', $databaseId); @@ -482,8 +474,6 @@ App::delete('/v1/databases/:databaseId') ->setPayload($response->output($database, Response::MODEL_DATABASE)) ; - $usage->setParam('databases.delete', 1); - $response->noContent(); }); @@ -494,6 +484,8 @@ App::post('/v1/databases/:databaseId/collections') ->label('event', 'databases.[databaseId].collections.[collectionId].create') ->label('scope', 'collections.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{response.$id}') + ->label('usage.metric', 'collections.{scope}.requests.create') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'createCollection') @@ -508,9 +500,8 @@ App::post('/v1/databases/:databaseId/collections') ->param('documentSecurity', false, new Boolean(true), 'Specifies the permissions model used in this collection, which accepts either \'collection\' or \'document\'. For \'collection\' level permission, the permissions specified in read and write params are applied to all documents in the collection. For \'document\' level permissions, read and write permissions are specified in each document. [learn more about permissions](https://appwrite.io/docs/permissions) and get a full list of available permissions.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, Response $response, Database $dbForProject, Event $events) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -520,11 +511,8 @@ App::post('/v1/databases/:databaseId/collections') $collectionId = $collectionId == 'unique()' ? ID::unique() : $collectionId; - /** - * Map aggregate permissions into the multiple permissions they represent, - * accounting for the resource type given that some types not allowed specific permissions. - */ - $permissions = PermissionsProcessor::aggregate($permissions, 'collection'); + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions); try { $dbForProject->createDocument('database_' . $database->getInternalId(), new Document([ @@ -540,9 +528,9 @@ App::post('/v1/databases/:databaseId/collections') $collection = $dbForProject->getDocument('database_' . $database->getInternalId(), $collectionId); $dbForProject->createCollection('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId()); - } catch (DuplicateException $th) { + } catch (DuplicateException) { throw new Exception(Exception::COLLECTION_ALREADY_EXISTS); - } catch (LimitException $th) { + } catch (LimitException) { throw new Exception(Exception::COLLECTION_LIMIT_EXCEEDED); } @@ -551,10 +539,6 @@ App::post('/v1/databases/:databaseId/collections') ->setParam('databaseId', $databaseId) ->setParam('collectionId', $collection->getId()); - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.create', 1); - $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($collection, Response::MODEL_COLLECTION); }); @@ -564,6 +548,8 @@ App::get('/v1/databases/:databaseId/collections') ->desc('List Collections') ->groups(['api', 'database']) ->label('scope', 'collections.read') + ->label('usage.metric', 'collections.{scope}.requests.read') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'listCollections') @@ -625,6 +611,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId') ->desc('Get Collection') ->groups(['api', 'database']) ->label('scope', 'collections.read') + ->label('usage.metric', 'collections.{scope}.requests.read') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'getCollection') @@ -636,8 +624,7 @@ App::get('/v1/databases/:databaseId/collections/:collectionId') ->param('collectionId', '', new UID(), 'Collection ID.') ->inject('response') ->inject('dbForProject') - ->inject('usage') - ->action(function (string $databaseId, string $collectionId, Response $response, Database $dbForProject, Stats $usage) { + ->action(function (string $databaseId, string $collectionId, Response $response, Database $dbForProject) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -651,10 +638,6 @@ App::get('/v1/databases/:databaseId/collections/:collectionId') throw new Exception(Exception::COLLECTION_NOT_FOUND); } - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.read', 1); - $response->dynamic($collection, Response::MODEL_COLLECTION); }); @@ -663,6 +646,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/logs') ->desc('List Collection Logs') ->groups(['api', 'database']) ->label('scope', 'collections.read') + ->label('usage.metric', 'collections.{scope}.requests.read') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'listCollectionLogs') @@ -759,6 +744,8 @@ App::put('/v1/databases/:databaseId/collections/:collectionId') ->label('scope', 'collections.write') ->label('event', 'databases.[databaseId].collections.[collectionId].update') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'updateCollection') @@ -774,9 +761,8 @@ App::put('/v1/databases/:databaseId/collections/:collectionId') ->param('enabled', true, new Boolean(), 'Is collection enabled?', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, Response $response, Database $dbForProject, Event $events) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -792,10 +778,8 @@ App::put('/v1/databases/:databaseId/collections/:collectionId') $permissions ??= $collection->getPermissions() ?? []; - /** - * Map aggregate permissions into the multiple permissions they represent. - */ - $permissions = PermissionsProcessor::aggregate($permissions, 'collection'); + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions); $enabled ??= $collection->getAttribute('enabled', true); @@ -812,10 +796,6 @@ App::put('/v1/databases/:databaseId/collections/:collectionId') throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, 'Bad structure. ' . $exception->getMessage()); } - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.update', 1); - $events ->setContext('database', $database) ->setParam('databaseId', $databaseId) @@ -831,6 +811,8 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId') ->label('scope', 'collections.write') ->label('event', 'databases.[databaseId].collections.[collectionId].delete') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.delete') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'deleteCollection') @@ -843,8 +825,7 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId') ->inject('dbForProject') ->inject('events') ->inject('deletes') - ->inject('usage') - ->action(function (string $databaseId, string $collectionId, Response $response, Database $dbForProject, Event $events, Delete $deletes, Stats $usage) { + ->action(function (string $databaseId, string $collectionId, Response $response, Database $dbForProject, Event $events, Delete $deletes) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -876,10 +857,6 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId') ->setPayload($response->output($collection, Response::MODEL_COLLECTION)) ; - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.delete', 1); - $response->noContent(); }); @@ -890,6 +867,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/string ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create') ->label('scope', 'collections.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'createStringAttribute') @@ -907,9 +886,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/string ->inject('response') ->inject('dbForProject') ->inject('database') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $collectionId, string $key, ?int $size, ?bool $required, ?string $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $collectionId, string $key, ?int $size, ?bool $required, ?string $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { // Ensure attribute default is within required size $validator = new Text($size); @@ -924,7 +902,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/string 'required' => $required, 'default' => $default, 'array' => $array, - ]), $response, $dbForProject, $database, $events, $usage); + ]), $response, $dbForProject, $database, $events); $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); $response->dynamic($attribute, Response::MODEL_ATTRIBUTE_STRING); @@ -937,6 +915,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/email' ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create') ->label('scope', 'collections.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.namespace', 'databases') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.method', 'createEmailAttribute') @@ -953,9 +933,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/email' ->inject('response') ->inject('dbForProject') ->inject('database') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { $attribute = createAttribute($databaseId, $collectionId, new Document([ 'key' => $key, @@ -965,7 +944,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/email' 'default' => $default, 'array' => $array, 'format' => APP_DATABASE_ATTRIBUTE_EMAIL, - ]), $response, $dbForProject, $database, $events, $usage); + ]), $response, $dbForProject, $database, $events); $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); $response->dynamic($attribute, Response::MODEL_ATTRIBUTE_EMAIL); @@ -978,6 +957,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/enum') ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create') ->label('scope', 'collections.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.namespace', 'databases') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.method', 'createEnumAttribute') @@ -995,9 +976,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/enum') ->inject('response') ->inject('dbForProject') ->inject('database') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $collectionId, string $key, array $elements, ?bool $required, ?string $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $collectionId, string $key, array $elements, ?bool $required, ?string $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { // use length of longest string as attribute size $size = 0; @@ -1022,7 +1002,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/enum') 'array' => $array, 'format' => APP_DATABASE_ATTRIBUTE_ENUM, 'formatOptions' => ['elements' => $elements], - ]), $response, $dbForProject, $database, $events, $usage); + ]), $response, $dbForProject, $database, $events); $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); $response->dynamic($attribute, Response::MODEL_ATTRIBUTE_ENUM); @@ -1035,6 +1015,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/ip') ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create') ->label('scope', 'collections.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.namespace', 'databases') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.method', 'createIpAttribute') @@ -1051,9 +1033,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/ip') ->inject('response') ->inject('dbForProject') ->inject('database') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { $attribute = createAttribute($databaseId, $collectionId, new Document([ 'key' => $key, @@ -1063,7 +1044,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/ip') 'default' => $default, 'array' => $array, 'format' => APP_DATABASE_ATTRIBUTE_IP, - ]), $response, $dbForProject, $database, $events, $usage); + ]), $response, $dbForProject, $database, $events); $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); $response->dynamic($attribute, Response::MODEL_ATTRIBUTE_IP); @@ -1076,6 +1057,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/url') ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create') ->label('scope', 'collections.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.namespace', 'databases') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.method', 'createUrlAttribute') @@ -1092,9 +1075,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/url') ->inject('response') ->inject('dbForProject') ->inject('database') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?string $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { $attribute = createAttribute($databaseId, $collectionId, new Document([ 'key' => $key, @@ -1104,7 +1086,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/url') 'default' => $default, 'array' => $array, 'format' => APP_DATABASE_ATTRIBUTE_URL, - ]), $response, $dbForProject, $database, $events, $usage); + ]), $response, $dbForProject, $database, $events); $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); $response->dynamic($attribute, Response::MODEL_ATTRIBUTE_URL); @@ -1117,6 +1099,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/intege ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create') ->label('scope', 'collections.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.namespace', 'databases') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.method', 'createIntegerAttribute') @@ -1135,9 +1119,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/intege ->inject('response') ->inject('dbForProject') ->inject('database') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?int $min, ?int $max, ?int $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { // Ensure attribute default is within range $min = (is_null($min)) ? PHP_INT_MIN : \intval($min); @@ -1167,7 +1150,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/intege 'min' => $min, 'max' => $max, ], - ]), $response, $dbForProject, $database, $events, $usage); + ]), $response, $dbForProject, $database, $events); $formatOptions = $attribute->getAttribute('formatOptions', []); @@ -1187,6 +1170,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/float' ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create') ->label('scope', 'collections.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.namespace', 'databases') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.method', 'createFloatAttribute') @@ -1206,8 +1191,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/float' ->inject('dbForProject') ->inject('database') ->inject('events') - ->inject('usage') - ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?float $min, ?float $max, ?float $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Event $events, Stats $usage) { + ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?float $min, ?float $max, ?float $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { // Ensure attribute default is within range $min = (is_null($min)) ? -PHP_FLOAT_MAX : \floatval($min); @@ -1240,7 +1224,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/float' 'min' => $min, 'max' => $max, ], - ]), $response, $dbForProject, $database, $events, $usage); + ]), $response, $dbForProject, $database, $events); $formatOptions = $attribute->getAttribute('formatOptions', []); @@ -1260,6 +1244,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/boolea ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].create') ->label('scope', 'collections.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.namespace', 'databases') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.method', 'createBooleanAttribute') @@ -1276,9 +1262,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/boolea ->inject('response') ->inject('dbForProject') ->inject('database') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?bool $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $collectionId, string $key, ?bool $required, ?bool $default, bool $array, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { $attribute = createAttribute($databaseId, $collectionId, new Document([ 'key' => $key, @@ -1287,7 +1272,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/attributes/boolea 'required' => $required, 'default' => $default, 'array' => $array, - ]), $response, $dbForProject, $database, $events, $usage); + ]), $response, $dbForProject, $database, $events); $response->setStatusCode(Response::STATUS_CODE_ACCEPTED); $response->dynamic($attribute, Response::MODEL_ATTRIBUTE_BOOLEAN); @@ -1341,6 +1326,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/attributes') ->desc('List Attributes') ->groups(['api', 'database']) ->label('scope', 'collections.read') + ->label('usage.metric', 'collections.{scope}.requests.read') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'listAttributes') @@ -1352,8 +1339,7 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/attributes') ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/database#createCollection).') ->inject('response') ->inject('dbForProject') - ->inject('usage') - ->action(function (string $databaseId, string $collectionId, Response $response, Database $dbForProject, Stats $usage) { + ->action(function (string $databaseId, string $collectionId, Response $response, Database $dbForProject) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -1368,10 +1354,6 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/attributes') $attributes = $collection->getAttribute('attributes'); - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.read', 1); - $response->dynamic(new Document([ 'total' => \count($attributes), 'attributes' => $attributes @@ -1383,6 +1365,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/attributes/:key') ->desc('Get Attribute') ->groups(['api', 'database']) ->label('scope', 'collections.read') + ->label('usage.metric', 'collections.{scope}.requests.read') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'getAttribute') @@ -1405,8 +1389,7 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/attributes/:key') ->param('key', '', new Key(), 'Attribute Key.') ->inject('response') ->inject('dbForProject') - ->inject('usage') - ->action(function (string $databaseId, string $collectionId, string $key, Response $response, Database $dbForProject, Stats $usage) { + ->action(function (string $databaseId, string $collectionId, string $key, Response $response, Database $dbForProject) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -1445,10 +1428,6 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/attributes/:key') default => Response::MODEL_ATTRIBUTE, }; - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.read', 1); - $response->dynamic($attribute, $model); }); @@ -1459,6 +1438,8 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/attributes/:key ->label('scope', 'collections.write') ->label('event', 'databases.[databaseId].collections.[collectionId].attributes.[attributeId].delete') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'deleteAttribute') @@ -1472,8 +1453,7 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/attributes/:key ->inject('dbForProject') ->inject('database') ->inject('events') - ->inject('usage') - ->action(function (string $databaseId, string $collectionId, string $key, Response $response, Database $dbForProject, EventDatabase $database, Event $events, Stats $usage) { + ->action(function (string $databaseId, string $collectionId, string $key, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { $db = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -1507,10 +1487,6 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/attributes/:key ->setDocument($attribute) ; - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.update', 1); - // Select response model based on type and format $type = $attribute->getAttribute('type'); $format = $attribute->getAttribute('format'); @@ -1549,6 +1525,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/indexes') ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].create') ->label('scope', 'collections.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'createIndex') @@ -1565,9 +1543,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/indexes') ->inject('response') ->inject('dbForProject') ->inject('database') - ->inject('usage') ->inject('events') - ->action(function (string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, Response $response, Database $dbForProject, EventDatabase $database, Stats $usage, Event $events) { + ->action(function (string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { $db = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -1677,10 +1654,6 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/indexes') ->setDocument($index) ; - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.update', 1); - $events ->setParam('databaseId', $databaseId) ->setParam('collectionId', $collection->getId()) @@ -1698,6 +1671,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/indexes') ->desc('List Indexes') ->groups(['api', 'database']) ->label('scope', 'collections.read') + ->label('usage.metric', 'collections.{scope}.requests.read') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'listIndexes') @@ -1709,8 +1684,7 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/indexes') ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/database#createCollection).') ->inject('response') ->inject('dbForProject') - ->inject('usage') - ->action(function (string $databaseId, string $collectionId, Response $response, Database $dbForProject, Stats $usage) { + ->action(function (string $databaseId, string $collectionId, Response $response, Database $dbForProject) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -1725,10 +1699,6 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/indexes') $indexes = $collection->getAttribute('indexes'); - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.read', 1); - $response->dynamic(new Document([ 'total' => \count($indexes), 'indexes' => $indexes, @@ -1740,6 +1710,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/indexes/:key') ->desc('Get Index') ->groups(['api', 'database']) ->label('scope', 'collections.read') + ->label('usage.metric', 'collections.{scope}.requests.read') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'getIndex') @@ -1752,8 +1724,7 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/indexes/:key') ->param('key', null, new Key(), 'Index Key.') ->inject('response') ->inject('dbForProject') - ->inject('usage') - ->action(function (string $databaseId, string $collectionId, string $key, Response $response, Database $dbForProject, Stats $usage) { + ->action(function (string $databaseId, string $collectionId, string $key, Response $response, Database $dbForProject) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -1779,10 +1750,6 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/indexes/:key') 'collectionId' => $database->getInternalId() . '_' . $collectionId, ])]); - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.read', 1); - $response->dynamic($index, Response::MODEL_INDEX); }); @@ -1793,6 +1760,8 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/indexes/:key') ->label('scope', 'collections.write') ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].delete') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'collections.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}']) ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'deleteIndex') @@ -1806,8 +1775,7 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/indexes/:key') ->inject('dbForProject') ->inject('database') ->inject('events') - ->inject('usage') - ->action(function (string $databaseId, string $collectionId, string $key, Response $response, Database $dbForProject, EventDatabase $database, Event $events, Stats $usage) { + ->action(function (string $databaseId, string $collectionId, string $key, Response $response, Database $dbForProject, EventDatabase $database, Event $events) { $db = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -1840,10 +1808,6 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/indexes/:key') ->setDocument($index) ; - $usage - ->setParam('databaseId', $databaseId) - ->setParam('databases.collections.update', 1); - $events ->setParam('databaseId', $databaseId) ->setParam('collectionId', $collection->getId()) @@ -1863,6 +1827,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].create') ->label('scope', 'documents.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('usage.metric', 'documents.{scope}.requests.create') + ->label('usage.params', ['databaseId:{request.databaseId}', 'collectionId:{request.collectionId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'createDocument') @@ -1878,10 +1844,9 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') ->inject('response') ->inject('dbForProject') ->inject('user') - ->inject('usage') ->inject('events') ->inject('mode') - ->action(function (string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, Response $response, Database $dbForProject, Document $user, Stats $usage, Event $events, string $mode) { + ->action(function (string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, Response $response, Database $dbForProject, Document $user, Event $events, string $mode) { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array @@ -1893,7 +1858,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') throw new Exception(Exception::DOCUMENT_INVALID_STRUCTURE, '$id is not allowed for creating new documents, try update instead'); } - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $database = Authorization::skip(fn() => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { throw new Exception(Exception::DATABASE_NOT_FOUND); @@ -1907,25 +1872,21 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') } } - $validator = new Authorization('create'); + $validator = new Authorization(Database::PERMISSION_CREATE); if (!$validator->isValid($collection->getCreate())) { throw new Exception(Exception::USER_UNAUTHORIZED); } - /** - * Map aggregate permissions into the multiple permissions they represent, - * accounting for the resource type given that some types not allowed specific permissions. - */ - $permissions = PermissionsProcessor::aggregate($permissions, 'document'); + $allowedPermissions = [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + ]; - /** - * Add permissions for current the user for any missing types - * from the allowed permissions for this resource type. - */ - $allowedPermissions = \array_filter( - Database::PERMISSIONS, - fn ($permission) => $permission !== Database::PERMISSION_CREATE - ); + // Map aggregate permissions to into the set of individual permissions they represent. + $permissions = Permission::aggregate($permissions, $allowedPermissions); + + // Add permissions for current the user if none were provided. if (\is_null($permissions)) { $permissions = []; if (!empty($user->getId())) { @@ -1933,19 +1894,8 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); } } - } else { - foreach ($allowedPermissions as $permission) { - /** - * If an allowed permission was not passed in the request, - * and there is a current user, add it for the current user. - */ - if (empty(\preg_grep("#^{$permission}\(.+\)$#", $permissions)) && !empty($user->getId())) { - $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); - } - } } - // Users can only manage their own roles, API keys and Admin users can manage any // Users can only manage their own roles, API keys and Admin users can manage any $roles = Authorization::getRoles(); if (!Auth::isAppUser($roles) && !Auth::isPrivilegedUser($roles)) { @@ -1961,7 +1911,7 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') $permission->getDimension() ))->toString(); if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', Authorization::getRoles()) . ')'); + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); } } } @@ -1988,12 +1938,6 @@ App::post('/v1/databases/:databaseId/collections/:collectionId/documents') ->setContext('database', $database) ; - $usage - ->setParam('databases.documents.create', 1) - ->setParam('databaseId', $databaseId) - ->setParam('collectionId', $collectionId) - ; - $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($document, Response::MODEL_DOCUMENT); }); @@ -2003,6 +1947,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents') ->desc('List Documents') ->groups(['api', 'database']) ->label('scope', 'documents.read') + ->label('usage.metric', 'documents.{scope}.requests.read') + ->label('usage.params', ['databaseId:{request.databaseId}', 'collectionId:{request.collectionId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'listDocuments') @@ -2015,7 +1961,6 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents') ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('mode') ->action(function (string $databaseId, string $collectionId, array $queries, Response $response, Database $dbForProject, Stats $usage, string $mode) { @@ -2034,9 +1979,9 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents') } $documentSecurity = $collection->getAttribute('documentSecurity', false); - $validator = new Authorization('read'); + $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($collection->getRead()); - if (!$valid && !$documentSecurity) { + if (!$documentSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } @@ -2057,7 +2002,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents') if ($cursor !== null) { /** @var Query $cursor */ $documentId = $cursor->getValue(); - if ($documentSecurity) { + + if ($documentSecurity && !$valid) { $cursorDocument = $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); } else { $cursorDocument = Authorization::skip(fn() => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId)); @@ -2072,7 +2018,7 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents') $filterQueries = Query::groupByType($queries)['filters']; - if ($documentSecurity) { + if ($documentSecurity && !$valid) { $documents = $dbForProject->find('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $queries); $total = $dbForProject->count('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $filterQueries, APP_LIMIT_COUNT); } else { @@ -2085,12 +2031,6 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents') */ $documents = array_map(fn(Document $document) => $document->setAttribute('$collection', $collectionId), $documents); - $usage - ->setParam('databases.documents.read', 1) - ->setParam('databaseId', $databaseId) - ->setParam('collectionId', $collectionId) - ; - $response->dynamic(new Document([ 'total' => $total, 'documents' => $documents, @@ -2102,6 +2042,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents/:documen ->desc('Get Document') ->groups(['api', 'database']) ->label('scope', 'documents.read') + ->label('usage.metric', 'documents.{scope}.requests.read') + ->label('usage.params', ['databaseId:{request.databaseId}', 'collectionId:{request.collectionId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'getDocument') @@ -2114,9 +2056,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents/:documen ->param('documentId', null, new UID(), 'Document ID.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('mode') - ->action(function (string $databaseId, string $collectionId, string $documentId, Response $response, Database $dbForProject, Stats $usage, string $mode) { + ->action(function (string $databaseId, string $collectionId, string $documentId, Response $response, Database $dbForProject, string $mode) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -2133,35 +2074,27 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents/:documen } $documentSecurity = $collection->getAttribute('documentSecurity', false); - $validator = new Authorization('read'); + $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($collection->getRead()); - if (!$valid && !$documentSecurity) { + if (!$documentSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } - $document = $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); + if ($documentSecurity && !$valid) { + $document = $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); + } else { + $document = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId)); + } if ($document->isEmpty()) { throw new Exception(Exception::DOCUMENT_NOT_FOUND); } - if ($documentSecurity) { - $valid |= $validator->isValid($document->getRead()); - if (!$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - } - /** * Reset $collection attribute to remove prefix. */ $document->setAttribute('$collection', $collectionId); - $usage - ->setParam('databases.documents.read', 1) - ->setParam('databaseId', $databaseId) - ->setParam('collectionId', $collectionId) - ; $response->dynamic($document, Response::MODEL_DOCUMENT); }); @@ -2171,6 +2104,8 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents/:documen ->desc('List Document Logs') ->groups(['api', 'database']) ->label('scope', 'documents.read') + ->label('usage.metric', 'documents.{scope}.requests.read') + ->label('usage.params', ['databaseId:{request.databaseId}', 'collectionId:{request.collectionId}']) ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'listDocumentLogs') @@ -2193,6 +2128,7 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents/:documen if ($database->isEmpty()) { throw new Exception(Exception::DATABASE_NOT_FOUND); } + $collection = $dbForProject->getDocument('database_' . $database->getInternalId(), $collectionId); if ($collection->isEmpty()) { @@ -2271,6 +2207,8 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].update') ->label('scope', 'documents.write') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}') + ->label('usage.metric', 'documents.{scope}.requests.update') + ->label('usage.params', ['databaseId:{request.databaseId}', 'collectionId:{request.collectionId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'updateDocument') @@ -2285,10 +2223,9 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of strings with permissions. By default no user is granted with any permissions. [learn more about permissions](/docs/permissions) and get a full list of available permissions.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') ->inject('mode') - ->action(function (string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, Response $response, Database $dbForProject, Stats $usage, Event $events, string $mode) { + ->action(function (string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, Response $response, Database $dbForProject, Event $events, string $mode) { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array @@ -2311,23 +2248,28 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum } $documentSecurity = $collection->getAttribute('documentSecurity', false); - $validator = new Authorization('update'); + $validator = new Authorization(Database::PERMISSION_UPDATE); $valid = $validator->isValid($collection->getUpdate()); - if (!$valid && !$documentSecurity) { + if (!$documentSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } - $document = $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); + // Read permission should not be required for update + $document = Authorization::skip(fn() => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId)); if ($document->isEmpty()) { throw new Exception(Exception::DOCUMENT_NOT_FOUND); } - if ($documentSecurity) { - $valid |= $validator->isValid($document->getUpdate()); - if (!$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions, [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + ]); + + if (\is_null($permissions)) { + $permissions = $document->getPermissions() ?? []; } // Users can only manage their own roles, API keys and Admin users can manage any @@ -2345,22 +2287,12 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum $permission->getDimension() ))->toString(); if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', Authorization::getRoles()) . ')'); + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); } } } } - /** - * Map aggregate permissions into the multiple permissions they represent, - * accounting for the resource type given that some types not allowed specific permissions. - */ - $permissions = PermissionsProcessor::aggregate($permissions, 'document'); - - if (\is_null($permissions)) { - $permissions = $document->getPermissions() ?? []; - } - $data = \array_merge($document->getArrayCopy(), $data); $data['$collection'] = $collection->getId(); // Make sure user doesn't switch collectionID $data['$createdAt'] = $document->getCreatedAt(); // Make sure user doesn't switch createdAt @@ -2368,14 +2300,16 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum $data['$permissions'] = $permissions; try { - $document = $dbForProject->updateDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $document->getId(), new Document($data)); + if ($documentSecurity && !$valid) { + $document = $dbForProject->updateDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $document->getId(), new Document($data)); + } else { + $document = Authorization::skip(fn() => $dbForProject->updateDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $document->getId(), new Document($data))); + } /** * Reset $collection attribute to remove prefix. */ $document->setAttribute('$collection', $collectionId); - } catch (AuthorizationException) { - throw new Exception(Exception::USER_UNAUTHORIZED); } catch (DuplicateException) { throw new Exception(Exception::DOCUMENT_ALREADY_EXISTS); } catch (StructureException $exception) { @@ -2390,12 +2324,6 @@ App::patch('/v1/databases/:databaseId/collections/:collectionId/documents/:docum ->setContext('database', $database) ; - $usage - ->setParam('databases.documents.update', 1) - ->setParam('databaseId', $databaseId) - ->setParam('collectionId', $collectionId) - ; - $response->dynamic($document, Response::MODEL_DOCUMENT); }); @@ -2406,6 +2334,8 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/documents/:docu ->label('scope', 'documents.write') ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].delete') ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{request.documentId}') + ->label('usage.metric', 'documents.{scope}.requests.delete') + ->label('usage.params', ['databaseId:{request.databaseId}', 'collectionId:{request.collectionId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'databases') ->label('sdk.method', 'deleteDocument') @@ -2419,9 +2349,8 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/documents/:docu ->inject('dbForProject') ->inject('events') ->inject('deletes') - ->inject('usage') ->inject('mode') - ->action(function (string $databaseId, string $collectionId, string $documentId, Response $response, Database $dbForProject, Event $events, Delete $deletes, Stats $usage, string $mode) { + ->action(function (string $databaseId, string $collectionId, string $documentId, Response $response, Database $dbForProject, Event $events, Delete $deletes, string $mode) { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -2438,27 +2367,25 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/documents/:docu } $documentSecurity = $collection->getAttribute('documentSecurity', false); - $validator = new Authorization('delete'); + $validator = new Authorization(Database::PERMISSION_DELETE); $valid = $validator->isValid($collection->getDelete()); - if (!$valid && !$documentSecurity) { + if (!$documentSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } - $document = $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); + // Read permission should not be required for delete + $document = Authorization::skip(fn() => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId)); if ($document->isEmpty()) { throw new Exception(Exception::DOCUMENT_NOT_FOUND); } - if ($documentSecurity) { - $valid |= $validator->isValid($document->getDelete()); - if (!$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } + if ($documentSecurity && !$valid) { + $dbForProject->deleteDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); + } else { + Authorization::skip(fn() => $dbForProject->deleteDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId)); } - $dbForProject->deleteDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); - $dbForProject->deleteCachedDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); /** @@ -2471,12 +2398,6 @@ App::delete('/v1/databases/:databaseId/collections/:collectionId/documents/:docu ->setDocument($document) ; - $usage - ->setParam('databases.documents.delete', 1) - ->setParam('databaseId', $databaseId) - ->setParam('collectionId', $collectionId) - ; - $events ->setParam('databaseId', $databaseId) ->setParam('collectionId', $collection->getId()) @@ -2526,21 +2447,21 @@ App::get('/v1/databases/usage') ]; $metrics = [ - 'databases.count', - 'databases.documents.count', - 'databases.collections.count', - 'databases.create', - 'databases.read', - 'databases.update', - 'databases.delete', - 'databases.collections.create', - 'databases.collections.read', - 'databases.collections.update', - 'databases.collections.delete', - 'databases.documents.create', - 'databases.documents.read', - 'databases.documents.update', - 'databases.documents.delete' + 'databases.$all.count.total', + 'documents.$all.count.total', + 'collections.$all.count.total', + 'databases.$all.requests.create', + 'databases.$all.requests.read', + 'databases.$all.requests.update', + 'databases.$all.requests.delete', + 'collections.$all.requests.create', + 'collections.$all.requests.read', + 'collections.$all.requests.update', + 'collections.$all.requests.delete', + 'documents.$all.requests.create', + 'documents.$all.requests.read', + 'documents.$all.requests.update', + 'documents.$all.requests.delete' ]; $stats = []; @@ -2586,21 +2507,21 @@ App::get('/v1/databases/usage') $usage = new Document([ 'range' => $range, - 'databasesCount' => $stats["databases.count"], - 'documentsCount' => $stats["databases.documents.count"], - 'collectionsCount' => $stats["databases.collections.count"], - 'documentsCreate' => $stats["databases.documents.create"], - 'documentsRead' => $stats["databases.documents.read"], - 'documentsUpdate' => $stats["databases.documents.update"], - 'documentsDelete' => $stats["databases.documents.delete"], - 'collectionsCreate' => $stats["databases.collections.create"], - 'collectionsRead' => $stats["databases.collections.read"], - 'collectionsUpdate' => $stats["databases.collections.update"], - 'collectionsDelete' => $stats["databases.collections.delete"], - 'databasesCreate' => $stats["databases.create"], - 'databasesRead' => $stats["databases.read"], - 'databasesUpdate' => $stats["databases.update"], - 'databasesDelete' => $stats["databases.delete"], + 'databasesCount' => $stats['databases.$all.count.total'] ?? [], + 'documentsCount' => $stats['documents.$all.count.total'] ?? [], + 'collectionsCount' => $stats['collections.$all.count.total'] ?? [], + 'documentsCreate' => $stats['documents.$all.requests.create'] ?? [], + 'documentsRead' => $stats['documents.$all.requests.read'] ?? [], + 'documentsUpdate' => $stats['documents.$all.requests.update'] ?? [], + 'documentsDelete' => $stats['documents.$all.requests.delete'] ?? [], + 'collectionsCreate' => $stats['collections.$all.requests.create'] ?? [], + 'collectionsRead' => $stats['collections.$all.requests.read'] ?? [], + 'collectionsUpdate' => $stats['collections.$all.requests.update'] ?? [], + 'collectionsDelete' => $stats['collections.$all.requests.delete'] ?? [], + 'databasesCreate' => $stats['databases.$all.requests.create'] ?? [], + 'databasesRead' => $stats['databases.$all.requests.read'] ?? [], + 'databasesUpdate' => $stats['databases.$all.requests.update'] ?? [], + 'databasesDelete' => $stats['databases.$all.requests.delete'] ?? [], ]); } @@ -2645,16 +2566,16 @@ App::get('/v1/databases/:databaseId/usage') ]; $metrics = [ - 'databases.' . $databaseId . '.documents.count', - 'databases.' . $databaseId . '.collections.count', - 'databases.' . $databaseId . '.collections.create', - 'databases.' . $databaseId . '.collections.read', - 'databases.' . $databaseId . '.collections.update', - 'databases.' . $databaseId . '.collections.delete', - 'databases.' . $databaseId . '.documents.create', - 'databases.' . $databaseId . '.documents.read', - 'databases.' . $databaseId . '.documents.update', - 'databases.' . $databaseId . '.documents.delete' + 'collections.' . $databaseId . '.count.total', + 'collections.' . $databaseId . '.requests.create', + 'collections.' . $databaseId . '.requests.read', + 'collections.' . $databaseId . '.requests.update', + 'collections.' . $databaseId . '.requests.delete', + 'documents.' . $databaseId . '.count.total', + 'documents.' . $databaseId . '.requests.create', + 'documents.' . $databaseId . '.requests.read', + 'documents.' . $databaseId . '.requests.update', + 'documents.' . $databaseId . '.requests.delete' ]; $stats = []; @@ -2700,16 +2621,16 @@ App::get('/v1/databases/:databaseId/usage') $usage = new Document([ 'range' => $range, - 'documentsCount' => $stats["databases.{$databaseId}.documents.count"], - 'collectionsCount' => $stats["databases.{$databaseId}.collections.count"], - 'documentsCreate' => $stats["databases.{$databaseId}.documents.create"], - 'documentsRead' => $stats["databases.{$databaseId}.documents.read"], - 'documentsUpdate' => $stats["databases.{$databaseId}.documents.update"], - 'documentsDelete' => $stats["databases.{$databaseId}.documents.delete"], - 'collectionsCreate' => $stats["databases.{$databaseId}.collections.create"], - 'collectionsRead' => $stats["databases.{$databaseId}.collections.read"], - 'collectionsUpdate' => $stats["databases.{$databaseId}.collections.update"], - 'collectionsDelete' => $stats["databases.{$databaseId}.collections.delete"], + 'collectionsCount' => $stats["collections.{$databaseId}.count.total"] ?? [], + 'collectionsCreate' => $stats["collections.{$databaseId}.requests.create"] ?? [], + 'collectionsRead' => $stats["collections.{$databaseId}.requests.read"] ?? [], + 'collectionsUpdate' => $stats["collections.{$databaseId}.requests.update"] ?? [], + 'collectionsDelete' => $stats["collections.{$databaseId}.requests.delete"] ?? [], + 'documentsCount' => $stats["documents.{$databaseId}.count.total"] ?? [], + 'documentsCreate' => $stats["documents.{$databaseId}.requests.create"] ?? [], + 'documentsRead' => $stats["documents.{$databaseId}.requests.read"] ?? [], + 'documentsUpdate' => $stats["documents.{$databaseId}.requests.update"] ?? [], + 'documentsDelete' => $stats["documents.{$databaseId}.requests.delete"] ?? [], ]); } @@ -2765,11 +2686,11 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/usage') ]; $metrics = [ - "databases.{$databaseId}.collections.{$collectionId}.documents.count", - "databases.{$databaseId}.collections.{$collectionId}.documents.create", - "databases.{$databaseId}.collections.{$collectionId}.documents.read", - "databases.{$databaseId}.collections.{$collectionId}.documents.update", - "databases.{$databaseId}.collections.{$collectionId}.documents.delete", + "documents.{$databaseId}/{$collectionId}.count.total", + "documents.{$databaseId}/{$collectionId}.requests.create", + "documents.{$databaseId}/{$collectionId}.requests.read", + "documents.{$databaseId}/{$collectionId}.requests.update", + "documents.{$databaseId}/{$collectionId}.requests.delete", ]; $stats = []; @@ -2814,11 +2735,11 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/usage') $usage = new Document([ 'range' => $range, - 'documentsCount' => $stats["databases.{$databaseId}.collections.{$collectionId}.documents.count"], - 'documentsCreate' => $stats["databases.{$databaseId}.collections.{$collectionId}.documents.create"], - 'documentsRead' => $stats["databases.{$databaseId}.collections.{$collectionId}.documents.read"], - 'documentsUpdate' => $stats["databases.{$databaseId}.collections.{$collectionId}.documents.update"], - 'documentsDelete' => $stats["databases.{$databaseId}.collections.{$collectionId}.documents.delete"] + 'documentsCount' => $stats["documents.{$databaseId}/{$collectionId}.count.total"] ?? [], + 'documentsCreate' => $stats["documents.{$databaseId}/{$collectionId}.requests.create"] ?? [], + 'documentsRead' => $stats["documents.{$databaseId}/{$collectionId}.requests.read"] ?? [], + 'documentsUpdate' => $stats["documents.{$databaseId}/{$collectionId}.requests.update"] ?? [], + 'documentsDelete' => $stats["documents.{$databaseId}/{$collectionId}.requests.delete" ?? []] ]); } diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 81be96d7ea..dff656c02d 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -13,7 +13,7 @@ use Utopia\Database\ID; use Utopia\Database\Permission; use Utopia\Database\Role; use Utopia\Database\Validator\UID; -use Appwrite\Stats\Stats; +use Appwrite\Usage\Stats; use Utopia\Storage\Device; use Utopia\Storage\Validator\File; use Utopia\Storage\Validator\FileExt; @@ -199,7 +199,7 @@ App::get('/v1/functions/:functionId/usage') ->label('scope', 'functions.read') ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN]) ->label('sdk.namespace', 'functions') - ->label('sdk.method', 'getUsage') + ->label('sdk.method', 'getFunctionUsage') ->label('sdk.response.code', Response::STATUS_CODE_OK) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_USAGE_FUNCTIONS) @@ -237,9 +237,14 @@ App::get('/v1/functions/:functionId/usage') ]; $metrics = [ - "functions.$functionId.executions", - "functions.$functionId.failures", - "functions.$functionId.compute" + "executions.$functionId.compute.total", + "executions.$functionId.compute.success", + "executions.$functionId.compute.failure", + "executions.$functionId.compute.time", + "builds.$functionId.compute.total", + "builds.$functionId.compute.success", + "builds.$functionId.compute.failure", + "builds.$functionId.compute.time", ]; $stats = []; @@ -284,9 +289,115 @@ App::get('/v1/functions/:functionId/usage') $usage = new Document([ 'range' => $range, - 'functionsExecutions' => $stats["functions.$functionId.executions"], - 'functionsFailures' => $stats["functions.$functionId.failures"], - 'functionsCompute' => $stats["functions.$functionId.compute"] + 'executionsTotal' => $stats["executions.$functionId.compute.total"] ?? [], + 'executionsFailure' => $stats["executions.$functionId.compute.failure"] ?? [], + 'executionsSuccesse' => $stats["executions.$functionId.compute.success"] ?? [], + 'executionsTime' => $stats["executions.$functionId.compute.time"] ?? [], + 'buildsTotal' => $stats["builds.$functionId.compute.total"] ?? [], + 'buildsFailure' => $stats["builds.$functionId.compute.failure"] ?? [], + 'buildsSuccess' => $stats["builds.$functionId.compute.success"] ?? [], + 'buildsTime' => $stats["builds.$functionId.compute.time" ?? []] + ]); + } + + $response->dynamic($usage, Response::MODEL_USAGE_FUNCTION); + }); + +App::get('/v1/functions/usage') + ->desc('Get Functions Usage') + ->groups(['api', 'functions']) + ->label('scope', 'functions.read') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN]) + ->label('sdk.namespace', 'functions') + ->label('sdk.method', 'getUsage') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_USAGE_FUNCTIONS) + ->param('range', '30d', new WhiteList(['24h', '7d', '30d', '90d']), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->action(function (string $range, Response $response, Database $dbForProject) { + + $usage = []; + if (App::getEnv('_APP_USAGE_STATS', 'enabled') == 'enabled') { + $periods = [ + '24h' => [ + 'period' => '30m', + 'limit' => 48, + ], + '7d' => [ + 'period' => '1d', + 'limit' => 7, + ], + '30d' => [ + 'period' => '1d', + 'limit' => 30, + ], + '90d' => [ + 'period' => '1d', + 'limit' => 90, + ], + ]; + + $metrics = [ + 'executions.$all.compute.total', + 'executions.$all.compute.failure', + 'executions.$all.compute.success', + 'executions.$all.compute.time', + 'builds.$all.compute.total', + 'builds.$all.compute.failure', + 'builds.$all.compute.success', + 'builds.$all.compute.time', + ]; + + $stats = []; + + Authorization::skip(function () use ($dbForProject, $periods, $range, $metrics, &$stats) { + foreach ($metrics as $metric) { + $limit = $periods[$range]['limit']; + $period = $periods[$range]['period']; + + $requestDocs = $dbForProject->find('stats', [ + new Query('period', Query::TYPE_EQUAL, [$period]), + new Query('metric', Query::TYPE_EQUAL, [$metric]), + ], $limit, 0, ['time'], [Database::ORDER_DESC]); + + $stats[$metric] = []; + foreach ($requestDocs as $requestDoc) { + $stats[$metric][] = [ + 'value' => $requestDoc->getAttribute('value'), + 'date' => $requestDoc->getAttribute('time'), + ]; + } + + // backfill metrics with empty values for graphs + $backfill = $limit - \count($requestDocs); + while ($backfill > 0) { + $last = $limit - $backfill - 1; // array index of last added metric + $diff = match ($period) { // convert period to seconds for unix timestamp math + '30m' => 1800, + '1d' => 86400, + }; + $stats[$metric][] = [ + 'value' => 0, + 'date' => ($stats[$metric][$last]['date'] ?? \time()) - $diff, // time of last metric minus period + ]; + $backfill--; + } + $stats[$metric] = array_reverse($stats[$metric]); + } + }); + + $usage = new Document([ + 'range' => $range, + 'executionsTotal' => $stats[$metrics[0]] ?? [], + 'executionsFailure' => $stats[$metrics[1]] ?? [], + 'executionsSuccess' => $stats[$metrics[2]] ?? [], + 'executionsTime' => $stats[$metrics[3]] ?? [], + 'buildsTotal' => $stats[$metrics[4]] ?? [], + 'buildsFailure' => $stats[$metrics[5]] ?? [], + 'buildsSuccess' => $stats[$metrics[6]] ?? [], + 'buildsTime' => $stats[$metrics[7]] ?? [], ]); } @@ -483,12 +594,11 @@ App::post('/v1/functions/:functionId/deployments') ->inject('request') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') ->inject('project') ->inject('deviceFunctions') ->inject('deviceLocal') - ->action(function (string $functionId, string $entrypoint, mixed $code, bool $activate, Request $request, Response $response, Database $dbForProject, Stats $usage, Event $events, Document $project, Device $deviceFunctions, Device $deviceLocal) { + ->action(function (string $functionId, string $entrypoint, mixed $code, bool $activate, Request $request, Response $response, Database $dbForProject, Event $events, Document $project, Device $deviceFunctions, Device $deviceLocal) { $function = $dbForProject->getDocument('functions', $functionId); @@ -615,8 +725,6 @@ App::post('/v1/functions/:functionId/deployments') ->setDeployment($deployment) ->setProject($project) ->trigger(); - - $usage->setParam('storage', $deployment->getAttribute('size', 0)); } else { if ($deployment->isEmpty()) { $deployment = $dbForProject->createDocument('deployments', new Document([ @@ -773,11 +881,10 @@ App::delete('/v1/functions/:functionId/deployments/:deploymentId') ->param('deploymentId', '', new UID(), 'Deployment ID.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('deletes') ->inject('events') ->inject('deviceFunctions') - ->action(function (string $functionId, string $deploymentId, Response $response, Database $dbForProject, Stats $usage, Delete $deletes, Event $events, Device $deviceFunctions) { + ->action(function (string $functionId, string $deploymentId, Response $response, Database $dbForProject, Delete $deletes, Event $events, Device $deviceFunctions) { $function = $dbForProject->getDocument('functions', $functionId); if ($function->isEmpty()) { @@ -805,9 +912,6 @@ App::delete('/v1/functions/:functionId/deployments/:deploymentId') ]))); } - $usage - ->setParam('storage', $deployment->getAttribute('size', 0) * -1); - $events ->setParam('functionId', $function->getId()) ->setParam('deploymentId', $deployment->getId()); @@ -993,11 +1097,12 @@ App::post('/v1/functions/:functionId/executions') Authorization::skip(fn () => $dbForProject->updateDocument('executions', $executionId, $execution)); + // TODO revise this later using route label $usage - ->setParam('functionId', $function->getId()) - ->setParam('functionExecution', 1) - ->setParam('functionStatus', $execution->getAttribute('status', '')) - ->setParam('functionExecutionTime', $execution->getAttribute('time') * 1000); // ms + ->setParam('functionId', $function->getId()) + ->setParam('executions.{scope}.compute', 1) + ->setParam('executionStatus', $execution->getAttribute('status', '')) + ->setParam('executionTime', $execution->getAttribute('time')); // ms $roles = Authorization::getRoles(); $isPrivilegedUser = Auth::isPrivilegedUser($roles); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index d8b755ba6b..86660bbdb4 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -291,13 +291,13 @@ App::get('/v1/projects/:projectId/usage') $dbForProject->setNamespace("_{$project->getInternalId()}"); $metrics = [ - 'requests', - 'network', - 'executions', - 'users.count', - 'databases.documents.count', - 'databases.collections.count', - 'storage.total' + 'project.$all.network.requests', + 'project.$all.network.bandwidth', + 'project.$all.storage.size', + 'users.$all.count.total', + 'collections.$all.count.total', + 'documents.$all.count.total', + 'executions.$all.compute.total', ]; $stats = []; @@ -342,13 +342,13 @@ App::get('/v1/projects/:projectId/usage') $usage = new Document([ 'range' => $range, - 'requests' => $stats['requests'], - 'network' => $stats['network'], - 'functions' => $stats['executions'], - 'documents' => $stats['databases.documents.count'], - 'collections' => $stats['databases.collections.count'], - 'users' => $stats['users.count'], - 'storage' => $stats['storage.total'] + 'requests' => $stats[$metrics[0]] ?? [], + 'network' => $stats[$metrics[1]] ?? [], + 'storage' => $stats[$metrics[2]] ?? [], + 'users' => $stats[$metrics[3]] ?? [], + 'collections' => $stats[$metrics[4]] ?? [], + 'documents' => $stats[$metrics[5]] ?? [], + 'executions' => $stats[$metrics[6]] ?? [], ]); } diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index c5adc47148..2ff83bf269 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -4,10 +4,9 @@ use Appwrite\Auth\Auth; use Appwrite\ClamAV\Network; use Appwrite\Event\Delete; use Appwrite\Event\Event; -use Appwrite\Permissions\PermissionsProcessor; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\OpenSSL\OpenSSL; -use Appwrite\Stats\Stats; +use Appwrite\Usage\Stats; use Appwrite\Utopia\Response; use Utopia\App; use Utopia\Config\Config; @@ -51,6 +50,7 @@ App::post('/v1/storage/buckets') ->label('scope', 'buckets.write') ->label('event', 'buckets.[bucketId].create') ->label('audits.resource', 'buckets/{response.$id}') + ->label('usage.metric', 'buckets.{scope}.requests.create') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'createBucket') @@ -69,17 +69,13 @@ App::post('/v1/storage/buckets') ->param('antivirus', true, new Boolean(true), 'Is virus scanning enabled? For file size above ' . Storage::human(APP_LIMIT_ANTIVIRUS, 0) . ' AntiVirus scanning is skipped even if it\'s enabled', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $bucketId, string $name, ?array $permissions, string $fileSecurity, bool $enabled, int $maximumFileSize, array $allowedFileExtensions, bool $encryption, bool $antivirus, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $bucketId, string $name, ?array $permissions, string $fileSecurity, bool $enabled, int $maximumFileSize, array $allowedFileExtensions, bool $encryption, bool $antivirus, Response $response, Database $dbForProject, Event $events) { $bucketId = $bucketId === 'unique()' ? ID::unique() : $bucketId; - /** - * Map aggregate permissions into the multiple permissions they represent, - * accounting for the resource type given that some types not allowed specific permissions. - */ - $permissions = PermissionsProcessor::aggregate($permissions, 'bucket'); + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions); try { $files = Config::getParam('collections', [])['files'] ?? []; @@ -139,8 +135,6 @@ App::post('/v1/storage/buckets') ->setParam('bucketId', $bucket->getId()) ; - $usage->setParam('storage.buckets.create', 1); - $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($bucket, Response::MODEL_BUCKET); }); @@ -149,6 +143,7 @@ App::get('/v1/storage/buckets') ->desc('List buckets') ->groups(['api', 'storage']) ->label('scope', 'buckets.read') + ->label('usage.metric', 'buckets.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'listBuckets') @@ -200,6 +195,7 @@ App::get('/v1/storage/buckets/:bucketId') ->desc('Get Bucket') ->groups(['api', 'storage']) ->label('scope', 'buckets.read') + ->label('usage.metric', 'buckets.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'getBucket') @@ -210,8 +206,7 @@ App::get('/v1/storage/buckets/:bucketId') ->param('bucketId', '', new UID(), 'Bucket unique ID.') ->inject('response') ->inject('dbForProject') - ->inject('usage') - ->action(function (string $bucketId, Response $response, Database $dbForProject, Stats $usage) { + ->action(function (string $bucketId, Response $response, Database $dbForProject) { $bucket = $dbForProject->getDocument('buckets', $bucketId); @@ -219,8 +214,6 @@ App::get('/v1/storage/buckets/:bucketId') throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } - $usage->setParam('storage.buckets.read', 1); - $response->dynamic($bucket, Response::MODEL_BUCKET); }); @@ -230,6 +223,7 @@ App::put('/v1/storage/buckets/:bucketId') ->label('scope', 'buckets.write') ->label('event', 'buckets.[bucketId].update') ->label('audits.resource', 'buckets/{response.$id}') + ->label('usage.metric', 'buckets.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'updateBucket') @@ -248,9 +242,8 @@ App::put('/v1/storage/buckets/:bucketId') ->param('antivirus', true, new Boolean(true), 'Is virus scanning enabled? For file size above ' . Storage::human(APP_LIMIT_ANTIVIRUS, 0) . ' AntiVirus scanning is skipped even if it\'s enabled', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $bucketId, string $name, ?array $permissions, string $fileSecurity, bool $enabled, ?int $maximumFileSize, array $allowedFileExtensions, bool $encryption, bool $antivirus, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $bucketId, string $name, ?array $permissions, string $fileSecurity, bool $enabled, ?int $maximumFileSize, array $allowedFileExtensions, bool $encryption, bool $antivirus, Response $response, Database $dbForProject, Event $events) { $bucket = $dbForProject->getDocument('buckets', $bucketId); if ($bucket->isEmpty()) { @@ -268,7 +261,8 @@ App::put('/v1/storage/buckets/:bucketId') * Map aggregate permissions into the multiple permissions they represent, * accounting for the resource type given that some types not allowed specific permissions. */ - $permissions = PermissionsProcessor::aggregate($permissions, 'bucket'); + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions); $bucket = $dbForProject->updateDocument('buckets', $bucket->getId(), $bucket ->setAttribute('name', $name) @@ -284,8 +278,6 @@ App::put('/v1/storage/buckets/:bucketId') ->setParam('bucketId', $bucket->getId()) ; - $usage->setParam('storage.buckets.update', 1); - $response->dynamic($bucket, Response::MODEL_BUCKET); }); @@ -295,6 +287,7 @@ App::delete('/v1/storage/buckets/:bucketId') ->label('scope', 'buckets.write') ->label('event', 'buckets.[bucketId].delete') ->label('audits.resource', 'buckets/{request.bucketId}') + ->label('usage.metric', 'buckets.{scope}.requests.delete') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'deleteBucket') @@ -306,8 +299,7 @@ App::delete('/v1/storage/buckets/:bucketId') ->inject('dbForProject') ->inject('deletes') ->inject('events') - ->inject('usage') - ->action(function (string $bucketId, Response $response, Database $dbForProject, Delete $deletes, Event $events, Stats $usage) { + ->action(function (string $bucketId, Response $response, Database $dbForProject, Delete $deletes, Event $events) { $bucket = $dbForProject->getDocument('buckets', $bucketId); if ($bucket->isEmpty()) { @@ -327,8 +319,6 @@ App::delete('/v1/storage/buckets/:bucketId') ->setPayload($response->output($bucket, Response::MODEL_BUCKET)) ; - $usage->setParam('storage.buckets.delete', 1); - $response->noContent(); }); @@ -339,6 +329,8 @@ App::post('/v1/storage/buckets/:bucketId/files') ->label('scope', 'files.write') ->label('event', 'buckets.[bucketId].files.[fileId].create') ->label('audits.resource', 'files/{response.$id}') + ->label('usage.metric', 'files.{scope}.requests.create') + ->label('usage.params', ['bucketId:{request.bucketId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'createFile') @@ -356,13 +348,12 @@ App::post('/v1/storage/buckets/:bucketId/files') ->inject('response') ->inject('dbForProject') ->inject('user') - ->inject('usage') ->inject('events') ->inject('mode') ->inject('deviceFiles') ->inject('deviceLocal') ->inject('deletes') - ->action(function (string $bucketId, string $fileId, mixed $file, ?array $permissions, Request $request, Response $response, Database $dbForProject, Document $user, Stats $usage, Event $events, string $mode, Device $deviceFiles, Device $deviceLocal, Delete $deletes) { + ->action(function (string $bucketId, string $fileId, mixed $file, ?array $permissions, Request $request, Response $response, Database $dbForProject, Document $user, Event $events, string $mode, Device $deviceFiles, Device $deviceLocal, Delete $deletes) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -370,25 +361,21 @@ App::post('/v1/storage/buckets/:bucketId/files') throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } - $validator = new Authorization('create'); + $validator = new Authorization(Database::PERMISSION_CREATE); if (!$validator->isValid($bucket->getCreate())) { throw new Exception(Exception::USER_UNAUTHORIZED); } - /** - * Map aggregate permissions into the multiple permissions they represent, - * accounting for the resource type given that some types not allowed specific permissions. - */ - $permissions = PermissionsProcessor::aggregate($permissions, 'file'); + $allowedPermissions = [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + ]; - /** - * Add permissions for current the user for any missing types - * from the allowed permissions for this resource type. - */ - $allowedPermissions = \array_filter( - Database::PERMISSIONS, - fn ($permission) => $permission !== Database::PERMISSION_CREATE - ); + // Map aggregate permissions to into the set of individual permissions they represent. + $permissions = Permission::aggregate($permissions, $allowedPermissions); + + // Add permissions for current the user if none were provided. if (\is_null($permissions)) { $permissions = []; if (!empty($user->getId())) { @@ -396,16 +383,6 @@ App::post('/v1/storage/buckets/:bucketId/files') $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); } } - } else { - foreach ($allowedPermissions as $permission) { - /** - * If an allowed permission was not passed in the request, - * and there is a current user, add it for the current user. - */ - if (empty(\preg_grep("#^{$permission}\(.+\)$#", $permissions)) && !empty($user->getId())) { - $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); - } - } } // Users can only manage their own roles, API keys and Admin users can manage any @@ -423,20 +400,12 @@ App::post('/v1/storage/buckets/:bucketId/files') $permission->getDimension() ))->toString(); if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', Authorization::getRoles()) . ')'); + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); } } } } - $file = $request->getFiles('file'); - - /** - * Validators - */ - $allowedFileExtensions = $bucket->getAttribute('allowedFileExtensions', []); - $fileExt = new FileExt($allowedFileExtensions); - $maximumFileSize = $bucket->getAttribute('maximumFileSize', 0); if ($maximumFileSize > (int) App::getEnv('_APP_STORAGE_LIMIT', 0)) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Maximum bucket file size is larger than _APP_STORAGE_LIMIT'); @@ -613,12 +582,6 @@ App::post('/v1/storage/buckets/:bucketId/files') } catch (DuplicateException) { throw new Exception(Exception::DOCUMENT_ALREADY_EXISTS); } - - $usage - ->setParam('storage', $sizeActual ?? 0) - ->setParam('storage.files.create', 1) - ->setParam('bucketId', $bucketId) - ; } else { try { if ($file->isEmpty()) { @@ -678,6 +641,8 @@ App::get('/v1/storage/buckets/:bucketId/files') ->groups(['api', 'storage']) ->label('scope', 'files.read') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('usage.metric', 'files.{scope}.requests.read') + ->label('usage.params', ['bucketId:{request.bucketId}']) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'listFiles') ->label('sdk.description', '/docs/references/storage/list-files.md') @@ -689,7 +654,6 @@ App::get('/v1/storage/buckets/:bucketId/files') ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('mode') ->action(function (string $bucketId, array $queries, string $search, Response $response, Database $dbForProject, Stats $usage, string $mode) { @@ -699,8 +663,10 @@ App::get('/v1/storage/buckets/:bucketId/files') throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); } - $validator = new Authorization('read'); - if (!$validator->isValid($bucket->getRead())) { + $fileSecurity = $bucket->getAttribute('fileSecurity', false); + $validator = new Authorization(Database::PERMISSION_READ); + $valid = $validator->isValid($bucket->getRead()); + if (!$fileSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } @@ -718,7 +684,12 @@ App::get('/v1/storage/buckets/:bucketId/files') if ($cursor !== null) { /** @var Query $cursor */ $fileId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + + if ($fileSecurity && !$valid) { + $cursorDocument = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + } else { + $cursorDocument = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); + } if ($cursorDocument->isEmpty()) { throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "File '{$fileId}' for the 'cursor' value not found."); @@ -729,7 +700,7 @@ App::get('/v1/storage/buckets/:bucketId/files') $filterQueries = Query::groupByType($queries)['filters']; - if ($bucket->getAttribute('fileSecurity', false)) { + if ($fileSecurity && !$valid) { $files = $dbForProject->find('bucket_' . $bucket->getInternalId(), $queries); $total = $dbForProject->count('bucket_' . $bucket->getInternalId(), $filterQueries, APP_LIMIT_COUNT); } else { @@ -737,11 +708,6 @@ App::get('/v1/storage/buckets/:bucketId/files') $total = Authorization::skip(fn () => $dbForProject->count('bucket_' . $bucket->getInternalId(), $filterQueries, APP_LIMIT_COUNT)); } - $usage - ->setParam('storage.files.read', 1) - ->setParam('bucketId', $bucketId) - ; - $response->dynamic(new Document([ 'files' => $files, 'total' => $total, @@ -754,6 +720,8 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId') ->groups(['api', 'storage']) ->label('scope', 'files.read') ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) + ->label('usage.metric', 'files.{scope}.requests.read') + ->label('usage.params', ['bucketId:{request.bucketId}']) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'getFile') ->label('sdk.description', '/docs/references/storage/get-file.md') @@ -764,9 +732,8 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId') ->param('fileId', '', new UID(), 'File ID.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('mode') - ->action(function (string $bucketId, string $fileId, Response $response, Database $dbForProject, Stats $usage, string $mode) { + ->action(function (string $bucketId, string $fileId, Response $response, Database $dbForProject, string $mode) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -775,30 +742,22 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId') } $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization('read'); + $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($bucket->getRead()); if (!$valid && !$fileSecurity) { throw new Exception(Exception::USER_UNAUTHORIZED); } - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + if ($fileSecurity && !$valid) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + } else { + $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); + } if ($file->isEmpty() || $file->getAttribute('bucketId') !== $bucketId) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } - if ($fileSecurity) { - $valid = $validator->isValid($file->getRead()); - if (!$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - } - - $usage - ->setParam('storage.files.read', 1) - ->setParam('bucketId', $bucketId) - ; - $response->dynamic($file, Response::MODEL_FILE); }); @@ -809,6 +768,8 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') ->label('scope', 'files.read') ->label('cache', true) ->label('cache.resource', 'file/{request.fileId}') + ->label('usage.metric', 'files.{scope}.requests.read') + ->label('usage.params', ['bucketId:{request.bucketId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'getFilePreview') @@ -833,11 +794,10 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') ->inject('response') ->inject('project') ->inject('dbForProject') - ->inject('usage') ->inject('mode') ->inject('deviceFiles') ->inject('deviceLocal') - ->action(function (string $bucketId, string $fileId, int $width, int $height, string $gravity, int $quality, int $borderWidth, string $borderColor, int $borderRadius, float $opacity, int $rotation, string $background, string $output, Request $request, Response $response, Document $project, Database $dbForProject, Stats $usage, string $mode, Device $deviceFiles, Device $deviceLocal) { + ->action(function (string $bucketId, string $fileId, int $width, int $height, string $gravity, int $quality, int $borderWidth, string $borderColor, int $borderRadius, float $opacity, int $rotation, string $background, string $output, Request $request, Response $response, Document $project, Database $dbForProject, string $mode, Device $deviceFiles, Device $deviceLocal) { if (!\extension_loaded('imagick')) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); @@ -850,9 +810,9 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') } $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization('read'); + $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($bucket->getRead()); - if (!$valid && !$fileSecurity) { + if (!$fileSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } @@ -867,19 +827,16 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') $date = \date('D, d M Y H:i:s', \time() + (60 * 60 * 24 * 45)) . ' GMT'; // 45 days cache $key = \md5($fileId . $width . $height . $gravity . $quality . $borderWidth . $borderColor . $borderRadius . $opacity . $rotation . $background . $output); - $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); + if ($fileSecurity && !$valid) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + } else { + $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); + } if ($file->isEmpty() || $file->getAttribute('bucketId') !== $bucketId) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } - if ($fileSecurity) { - $valid |= $validator->isValid($file->getRead()); - if (!$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - } - $path = $file->getAttribute('path'); $type = \strtolower(\pathinfo($path, PATHINFO_EXTENSION)); $algorithm = $file->getAttribute('algorithm'); @@ -957,11 +914,6 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') $data = $image->output($output, $quality); - $usage - ->setParam('storage.files.read', 1) - ->setParam('bucketId', $bucketId) - ; - $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg']; $response @@ -969,6 +921,7 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') ->setContentType($contentType) ->file($data) ; + unset($image); }); @@ -977,6 +930,8 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') ->desc('Get File for Download') ->groups(['api', 'storage']) ->label('scope', 'files.read') + ->label('usage.metric', 'files.{scope}.requests.read') + ->label('usage.params', ['bucketId:{request.bucketId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'getFileDownload') @@ -989,10 +944,9 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') ->inject('request') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('mode') ->inject('deviceFiles') - ->action(function (string $bucketId, string $fileId, Request $request, Response $response, Database $dbForProject, Stats $usage, string $mode, Device $deviceFiles) { + ->action(function (string $bucketId, string $fileId, Request $request, Response $response, Database $dbForProject, string $mode, Device $deviceFiles) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -1001,36 +955,28 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/download') } $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization('read'); + $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($bucket->getRead()); - if (!$valid && !$fileSecurity) { + if (!$fileSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + if ($fileSecurity && !$valid) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + } else { + $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); + } if ($file->isEmpty() || $file->getAttribute('bucketId') !== $bucketId) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } - if ($fileSecurity) { - $valid |= $validator->isValid($file->getRead()); - if (!$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - } - $path = $file->getAttribute('path', ''); if (!$deviceFiles->exists($path)) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); } - $usage - ->setParam('storage.files.read', 1) - ->setParam('bucketId', $bucketId) - ; - $response ->setContentType($file->getAttribute('mimeType')) ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + (60 * 60 * 24 * 45)) . ' GMT') // 45 days cache @@ -1115,6 +1061,8 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') ->desc('Get File for View') ->groups(['api', 'storage']) ->label('scope', 'files.read') + ->label('usage.metric', 'files.{scope}.requests.read') + ->label('usage.params', ['bucketId:{request.bucketId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'getFileView') @@ -1127,10 +1075,9 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') ->inject('response') ->inject('request') ->inject('dbForProject') - ->inject('usage') ->inject('mode') ->inject('deviceFiles') - ->action(function (string $bucketId, string $fileId, Response $response, Request $request, Database $dbForProject, Stats $usage, string $mode, Device $deviceFiles) { + ->action(function (string $bucketId, string $fileId, Response $response, Request $request, Database $dbForProject, string $mode, Device $deviceFiles) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -1139,25 +1086,22 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') } $fileSecurity = $bucket->getAttribute('fileSecurity', false); - $validator = new Authorization('read'); + $validator = new Authorization(Database::PERMISSION_READ); $valid = $validator->isValid($bucket->getRead()); - if (!$valid && !$fileSecurity) { + if (!$fileSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + if ($fileSecurity && !$valid) { + $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + } else { + $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); + } if ($file->isEmpty() || $file->getAttribute('bucketId') !== $bucketId) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } - if ($fileSecurity) { - $valid |= !$validator->isValid($file->getRead()); - if (!$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - } - $mimes = Config::getParam('storage-mimes'); $path = $file->getAttribute('path', ''); @@ -1225,11 +1169,6 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/view') $source = $compressor->decompress($source); } - $usage - ->setParam('storage.files.read', 1) - ->setParam('bucketId', $bucketId) - ; - if (!empty($source)) { if (!empty($rangeHeader)) { $response->send(substr($source, $start, ($end - $start + 1))); @@ -1266,6 +1205,8 @@ App::put('/v1/storage/buckets/:bucketId/files/:fileId') ->label('scope', 'files.write') ->label('event', 'buckets.[bucketId].files.[fileId].update') ->label('audits.resource', 'files/{response.$id}') + ->label('usage.metric', 'files.{scope}.requests.update') + ->label('usage.params', ['bucketId:{request.bucketId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'updateFile') @@ -1279,10 +1220,9 @@ App::put('/v1/storage/buckets/:bucketId/files/:fileId') ->inject('response') ->inject('dbForProject') ->inject('user') - ->inject('usage') ->inject('mode') ->inject('events') - ->action(function (string $bucketId, string $fileId, ?array $permissions, Response $response, Database $dbForProject, Document $user, Stats $usage, string $mode, Event $events) { + ->action(function (string $bucketId, string $fileId, ?array $permissions, Response $response, Database $dbForProject, Document $user, string $mode, Event $events) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -1291,26 +1231,30 @@ App::put('/v1/storage/buckets/:bucketId/files/:fileId') } $fileSecurity = $bucket->getAttributes('fileSecurity', false); - $validator = new Authorization('update'); + $validator = new Authorization(Database::PERMISSION_UPDATE); $valid = $validator->isValid($bucket->getUpdate()); - if (!$valid && !$fileSecurity) { + if (!$fileSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + // Read permission should not be required for update + $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); if ($file->isEmpty() || $file->getAttribute('bucketId') !== $bucketId) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } - if ($fileSecurity) { - $valid |= $validator->isValid($file->getUpdate()); - if (!$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions, [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + ]); + + if (\is_null($permissions)) { + $permissions = $file->getPermissions() ?? []; } - // Users can only manage their own roles, API keys and Admin users can manage any // Users can only manage their own roles, API keys and Admin users can manage any $roles = Authorization::getRoles(); if (!Auth::isAppUser($roles) && !Auth::isPrivilegedUser($roles)) { @@ -1326,18 +1270,12 @@ App::put('/v1/storage/buckets/:bucketId/files/:fileId') $permission->getDimension() ))->toString(); if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', Authorization::getRoles()) . ')'); + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); } } } } - /** - * Map aggregate permissions into the multiple permissions they represent, - * accounting for the resource type given that some types not allowed specific permissions. - */ - $permissions = PermissionsProcessor::aggregate($permissions, 'file'); - $file->setAttribute('$permissions', $permissions); $file = $dbForProject->updateDocument('bucket_' . $bucket->getInternalId(), $fileId, $file); @@ -1348,11 +1286,6 @@ App::put('/v1/storage/buckets/:bucketId/files/:fileId') ->setContext('bucket', $bucket) ; - $usage - ->setParam('storage.files.update', 1) - ->setParam('bucketId', $bucketId) - ; - $response->dynamic($file, Response::MODEL_FILE); }); @@ -1363,6 +1296,8 @@ App::delete('/v1/storage/buckets/:bucketId/files/:fileId') ->label('scope', 'files.write') ->label('event', 'buckets.[bucketId].files.[fileId].delete') ->label('audits.resource', 'file/{request.fileId}') + ->label('usage.metric', 'files.{scope}.requests.delete') + ->label('usage.params', ['bucketId:{request.bucketId}']) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_JWT]) ->label('sdk.namespace', 'storage') ->label('sdk.method', 'deleteFile') @@ -1374,11 +1309,10 @@ App::delete('/v1/storage/buckets/:bucketId/files/:fileId') ->inject('response') ->inject('dbForProject') ->inject('events') - ->inject('usage') ->inject('mode') ->inject('deviceFiles') ->inject('deletes') - ->action(function (string $bucketId, string $fileId, Response $response, Database $dbForProject, Event $events, Stats $usage, string $mode, Device $deviceFiles, Delete $deletes) { + ->action(function (string $bucketId, string $fileId, Response $response, Database $dbForProject, Event $events, string $mode, Device $deviceFiles, Delete $deletes) { $bucket = Authorization::skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && $mode !== APP_MODE_ADMIN)) { @@ -1386,25 +1320,19 @@ App::delete('/v1/storage/buckets/:bucketId/files/:fileId') } $fileSecurity = $bucket->getAttributes('fileSecurity', false); - $validator = new Authorization('delete'); + $validator = new Authorization(Database::PERMISSION_DELETE); $valid = $validator->isValid($bucket->getDelete()); - if (!$valid && !$fileSecurity) { + if (!$fileSecurity && !$valid) { throw new Exception(Exception::USER_UNAUTHORIZED); } - $file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId); + // Read permission should not be required for delete + $file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId)); if ($file->isEmpty() || $file->getAttribute('bucketId') !== $bucketId) { throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } - if ($fileSecurity) { - $valid |= $validator->isValid($file->getDelete()); - if (!$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - } - $deviceDeleted = false; if ($file->getAttribute('chunksTotal') !== $file->getAttribute('chunksUploaded')) { $deviceDeleted = $deviceFiles->abort( @@ -1421,7 +1349,11 @@ App::delete('/v1/storage/buckets/:bucketId/files/:fileId') ->setResource('file/' . $fileId) ; - $deleted = $dbForProject->deleteDocument('bucket_' . $bucket->getInternalId(), $fileId); + if ($fileSecurity && !$valid) { + $deleted = $dbForProject->deleteDocument('bucket_' . $bucket->getInternalId(), $fileId); + } else { + $deleted = Authorization::skip(fn() => $dbForProject->deleteDocument('bucket_' . $bucket->getInternalId(), $fileId)); + } if (!$deleted) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove file from DB'); @@ -1430,12 +1362,6 @@ App::delete('/v1/storage/buckets/:bucketId/files/:fileId') throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to delete file from device'); } - $usage - ->setParam('storage', $file->getAttribute('size', 0) * -1) - ->setParam('storage.files.delete', 1) - ->setParam('bucketId', $bucketId) - ; - $events ->setParam('bucketId', $bucket->getId()) ->setParam('fileId', $file->getId()) @@ -1483,18 +1409,18 @@ App::get('/v1/storage/usage') ]; $metrics = [ - "storage.deployments.total", - "storage.files.total", - "storage.files.count", - "storage.buckets.count", - "storage.buckets.create", - "storage.buckets.read", - "storage.buckets.update", - "storage.buckets.delete", - "storage.files.create", - "storage.files.read", - "storage.files.update", - "storage.files.delete", + 'project.$all.storage.size', + 'buckets.$all.count.total', + 'buckets.$all.requests.create', + 'buckets.$all.requests.read', + 'buckets.$all.requests.update', + 'buckets.$all.requests.delete', + 'files.$all.storage.size', + 'files.$all.count.total', + 'files.$all.requests.create', + 'files.$all.requests.read', + 'files.$all.requests.update', + 'files.$all.requests.delete', ]; $stats = []; @@ -1539,18 +1465,17 @@ App::get('/v1/storage/usage') $usage = new Document([ 'range' => $range, - 'filesStorage' => $stats['storage.files.total'], - 'deploymentsStorage' => $stats['storage.deployments.total'], - 'filesCount' => $stats['storage.files.count'], - 'bucketsCount' => $stats['storage.buckets.count'], - 'bucketsCreate' => $stats['storage.buckets.create'], - 'bucketsRead' => $stats['storage.buckets.read'], - 'bucketsUpdate' => $stats['storage.buckets.update'], - 'bucketsDelete' => $stats['storage.buckets.delete'], - 'filesCreate' => $stats['storage.files.create'], - 'filesRead' => $stats['storage.files.read'], - 'filesUpdate' => $stats['storage.files.update'], - 'filesDelete' => $stats['storage.files.delete'], + 'bucketsCount' => $stats['buckets.$all.count.total'], + 'bucketsCreate' => $stats['buckets.$all.requests.create'], + 'bucketsRead' => $stats['buckets.$all.requests.read'], + 'bucketsUpdate' => $stats['buckets.$all.requests.update'], + 'bucketsDelete' => $stats['buckets.$all.requests.delete'], + 'storage' => $stats['project.$all.storage.size'], + 'filesCount' => $stats['files.$all.count.total'], + 'filesCreate' => $stats['files.$all.requests.create'], + 'filesRead' => $stats['files.$all.requests.read'], + 'filesUpdate' => $stats['files.$all.requests.update'], + 'filesDelete' => $stats['files.$all.requests.delete'], ]); } @@ -1601,12 +1526,12 @@ App::get('/v1/storage/:bucketId/usage') ]; $metrics = [ - "storage.buckets.$bucketId.files.count", - "storage.buckets.$bucketId.files.total", - "storage.buckets.$bucketId.files.create", - "storage.buckets.$bucketId.files.read", - "storage.buckets.$bucketId.files.update", - "storage.buckets.$bucketId.files.delete", + "files.{$bucketId}.count.total", + "files.{$bucketId}.storage.size", + "files.{$bucketId}.requests.create", + "files.{$bucketId}.requests.read", + "files.{$bucketId}.requests.update", + "files.{$bucketId}.requests.delete", ]; $stats = []; @@ -1650,12 +1575,12 @@ App::get('/v1/storage/:bucketId/usage') $usage = new Document([ 'range' => $range, - 'filesStorage' => $stats["storage.buckets.$bucketId.files.total"], - 'filesCount' => $stats["storage.buckets.$bucketId.files.count"], - 'filesCreate' => $stats["storage.buckets.$bucketId.files.create"], - 'filesRead' => $stats["storage.buckets.$bucketId.files.read"], - 'filesUpdate' => $stats["storage.buckets.$bucketId.files.update"], - 'filesDelete' => $stats["storage.buckets.$bucketId.files.delete"], + 'filesCount' => $stats[$metrics[0]], + 'filesStorage' => $stats[$metrics[1]], + 'filesCreate' => $stats[$metrics[2]], + 'filesRead' => $stats[$metrics[3]], + 'filesUpdate' => $stats[$metrics[4]], + 'filesDelete' => $stats[$metrics[5]], ]); } diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 47b8c1b0cb..d5f7b88ca8 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -70,8 +70,8 @@ App::post('/v1/teams') '$id' => $teamId, '$permissions' => [ Permission::read(Role::team($teamId)), - Permission::update(Role::team($teamId), 'owner'), - Permission::delete(Role::team($teamId), 'owner'), + Permission::update(Role::team($teamId, 'owner')), + Permission::delete(Role::team($teamId, 'owner')), ], 'name' => $name, 'total' => ($isPrivilegedUser || $isAppUser) ? 0 : 1, @@ -821,14 +821,6 @@ App::delete('/v1/teams/:teamId/memberships/:membershipId') throw new Exception(Exception::TEAM_NOT_FOUND); } - /** - * Force document security - */ - $validator = new Authorization('delete'); - if (!$validator->isValid($membership->getDelete())) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - try { $dbForProject->deleteDocument('memberships', $membership->getId()); } catch (AuthorizationException $exception) { diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 3856faeee0..c99d3b41d0 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -7,7 +7,6 @@ use Appwrite\Detector\Detector; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Network\Validator\Email; -use Appwrite\Stats\Stats; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries; use Appwrite\Utopia\Database\Validator\Queries\Users; @@ -37,7 +36,7 @@ use MaxMind\Db\Reader; use Utopia\Validator\Integer; /** TODO: Remove function when we move to using utopia/platform */ -function createUser(string $hash, mixed $hashOptions, string $userId, ?string $email, ?string $password, ?string $phone, string $name, Database $dbForProject, Stats $usage, Event $events): Document +function createUser(string $hash, mixed $hashOptions, string $userId, ?string $email, ?string $password, ?string $phone, string $name, Database $dbForProject, Event $events): Document { $hashOptionsObject = (\is_string($hashOptions)) ? \json_decode($hashOptions, true) : $hashOptions; // Cast to JSON array @@ -79,8 +78,6 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e throw new Exception(Exception::USER_ALREADY_EXISTS); } - $usage->setParam('users.create', 1); - $events->setParam('userId', $user->getId()); return $user; @@ -92,6 +89,7 @@ App::post('/v1/users') ->label('event', 'users.[userId].create') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.create') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'create') @@ -106,10 +104,9 @@ App::post('/v1/users') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Database $dbForProject, Stats $usage, Event $events) { - $user = createUser('plaintext', '{}', $userId, $email, $password, $phone, $name, $dbForProject, $usage, $events); + ->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Database $dbForProject, Event $events) { + $user = createUser('plaintext', '{}', $userId, $email, $password, $phone, $name, $dbForProject, $events); $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($user, Response::MODEL_USER); @@ -121,6 +118,7 @@ App::post('/v1/users/bcrypt') ->label('event', 'users.[userId].create') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.create') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'createBcryptUser') @@ -134,10 +132,9 @@ App::post('/v1/users/bcrypt') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Stats $usage, Event $events) { - $user = createUser('bcrypt', '{}', $userId, $email, $password, null, $name, $dbForProject, $usage, $events); + ->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Event $events) { + $user = createUser('bcrypt', '{}', $userId, $email, $password, null, $name, $dbForProject, $events); $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($user, Response::MODEL_USER); @@ -149,6 +146,7 @@ App::post('/v1/users/md5') ->label('event', 'users.[userId].create') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.create') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'createMD5User') @@ -162,10 +160,9 @@ App::post('/v1/users/md5') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Stats $usage, Event $events) { - $user = createUser('md5', '{}', $userId, $email, $password, null, $name, $dbForProject, $usage, $events); + ->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Event $events) { + $user = createUser('md5', '{}', $userId, $email, $password, null, $name, $dbForProject, $events); $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($user, Response::MODEL_USER); @@ -177,6 +174,7 @@ App::post('/v1/users/argon2') ->label('event', 'users.[userId].create') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.create') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'createArgon2User') @@ -190,10 +188,9 @@ App::post('/v1/users/argon2') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Stats $usage, Event $events) { - $user = createUser('argon2', '{}', $userId, $email, $password, null, $name, $dbForProject, $usage, $events); + ->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Event $events) { + $user = createUser('argon2', '{}', $userId, $email, $password, null, $name, $dbForProject, $events); $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($user, Response::MODEL_USER); @@ -205,6 +202,7 @@ App::post('/v1/users/sha') ->label('event', 'users.[userId].create') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.create') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'createSHAUser') @@ -219,16 +217,15 @@ App::post('/v1/users/sha') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $email, string $password, string $passwordVersion, string $name, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, string $email, string $password, string $passwordVersion, string $name, Response $response, Database $dbForProject, Event $events) { $options = '{}'; if (!empty($passwordVersion)) { $options = '{"version":"' . $passwordVersion . '"}'; } - $user = createUser('sha', $options, $userId, $email, $password, null, $name, $dbForProject, $usage, $events); + $user = createUser('sha', $options, $userId, $email, $password, null, $name, $dbForProject, $events); $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($user, Response::MODEL_USER); @@ -240,6 +237,7 @@ App::post('/v1/users/phpass') ->label('event', 'users.[userId].create') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.create') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'createPHPassUser') @@ -253,10 +251,9 @@ App::post('/v1/users/phpass') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Stats $usage, Event $events) { - $user = createUser('phpass', '{}', $userId, $email, $password, null, $name, $dbForProject, $usage, $events); + ->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Event $events) { + $user = createUser('phpass', '{}', $userId, $email, $password, null, $name, $dbForProject, $events); $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($user, Response::MODEL_USER); @@ -268,6 +265,7 @@ App::post('/v1/users/scrypt') ->label('event', 'users.[userId].create') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.create') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'createScryptUser') @@ -286,9 +284,8 @@ App::post('/v1/users/scrypt') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, string $name, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, string $name, Response $response, Database $dbForProject, Event $events) { $options = [ 'salt' => $passwordSalt, 'costCpu' => $passwordCpu, @@ -297,7 +294,7 @@ App::post('/v1/users/scrypt') 'length' => $passwordLength ]; - $user = createUser('scrypt', \json_encode($options), $userId, $email, $password, null, $name, $dbForProject, $usage, $events); + $user = createUser('scrypt', \json_encode($options), $userId, $email, $password, null, $name, $dbForProject, $events); $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($user, Response::MODEL_USER); @@ -309,6 +306,7 @@ App::post('/v1/users/scrypt-modified') ->label('event', 'users.[userId].create') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.create') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'createScryptModifiedUser') @@ -325,10 +323,9 @@ App::post('/v1/users/scrypt-modified') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, string $name, Response $response, Database $dbForProject, Stats $usage, Event $events) { - $user = createUser('scryptMod', '{"signerKey":"' . $passwordSignerKey . '","saltSeparator":"' . $passwordSaltSeparator . '","salt":"' . $passwordSalt . '"}', $userId, $email, $password, null, $name, $dbForProject, $usage, $events); + ->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, string $name, Response $response, Database $dbForProject, Event $events) { + $user = createUser('scryptMod', '{"signerKey":"' . $passwordSignerKey . '","saltSeparator":"' . $passwordSaltSeparator . '","salt":"' . $passwordSalt . '"}', $userId, $email, $password, null, $name, $dbForProject, $events); $response->setStatusCode(Response::STATUS_CODE_CREATED); $response->dynamic($user, Response::MODEL_USER); @@ -338,6 +335,7 @@ App::get('/v1/users') ->desc('List Users') ->groups(['api', 'users']) ->label('scope', 'users.read') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'list') @@ -389,6 +387,7 @@ App::get('/v1/users/:userId') ->desc('Get User') ->groups(['api', 'users']) ->label('scope', 'users.read') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'get') @@ -399,41 +398,7 @@ App::get('/v1/users/:userId') ->param('userId', '', new UID(), 'User ID.') ->inject('response') ->inject('dbForProject') - ->inject('usage') - ->action(function (string $userId, Response $response, Database $dbForProject, Stats $usage) { - - $user = $dbForProject->getDocument('users', $userId); - - if ($user->isEmpty()) { - throw new Exception(Exception::USER_NOT_FOUND); - } - - $usage->setParam('users.read', 1); - - $response->dynamic($user, Response::MODEL_USER); - }); - -App::patch('/v1/users/:userId/prefs') - ->desc('Update User Preferences') - ->groups(['api', 'users']) - ->label('event', 'users.[userId].update.prefs') - ->label('scope', 'users.write') - ->label('audits.resource', 'user/{request.userId}') - ->label('audits.userId', '{request.userId}') - ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) - ->label('sdk.namespace', 'users') - ->label('sdk.method', 'updatePrefs') - ->label('sdk.description', '/docs/references/users/update-user-prefs.md') - ->label('sdk.response.code', Response::STATUS_CODE_OK) - ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) - ->label('sdk.response.model', Response::MODEL_PREFERENCES) - ->param('userId', '', new UID(), 'User ID.') - ->param('prefs', '', new Assoc(), 'Prefs key-value JSON object.') - ->inject('response') - ->inject('dbForProject') - ->inject('usage') - ->inject('events') - ->action(function (string $userId, array $prefs, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, Response $response, Database $dbForProject) { $user = $dbForProject->getDocument('users', $userId); @@ -441,19 +406,14 @@ App::patch('/v1/users/:userId/prefs') throw new Exception('User not found', 404, Exception::USER_NOT_FOUND); } - $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('prefs', $prefs)); - - $usage->setParam('users.update', 1); - - $events->setParam('userId', $user->getId()); - - $response->dynamic(new Document($prefs), Response::MODEL_PREFERENCES); + $response->dynamic($user, Response::MODEL_USER); }); App::get('/v1/users/:userId/prefs') ->desc('Get User Preferences') ->groups(['api', 'users']) ->label('scope', 'users.read') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'getPrefs') @@ -464,8 +424,7 @@ App::get('/v1/users/:userId/prefs') ->param('userId', '', new UID(), 'User ID.') ->inject('response') ->inject('dbForProject') - ->inject('usage') - ->action(function (string $userId, Response $response, Database $dbForProject, Stats $usage) { + ->action(function (string $userId, Response $response, Database $dbForProject) { $user = $dbForProject->getDocument('users', $userId); @@ -475,8 +434,6 @@ App::get('/v1/users/:userId/prefs') $prefs = $user->getAttribute('prefs', new \stdClass()); - $usage->setParam('users.read', 1); - $response->dynamic(new Document($prefs), Response::MODEL_PREFERENCES); }); @@ -484,6 +441,7 @@ App::get('/v1/users/:userId/sessions') ->desc('Get User Sessions') ->groups(['api', 'users']) ->label('scope', 'users.read') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'getSessions') @@ -495,8 +453,7 @@ App::get('/v1/users/:userId/sessions') ->inject('response') ->inject('dbForProject') ->inject('locale') - ->inject('usage') - ->action(function (string $userId, Response $response, Database $dbForProject, Locale $locale, Stats $usage) { + ->action(function (string $userId, Response $response, Database $dbForProject, Locale $locale) { $user = $dbForProject->getDocument('users', $userId); @@ -516,8 +473,6 @@ App::get('/v1/users/:userId/sessions') $sessions[$key] = $session; } - $usage->setParam('users.read', 1); - $response->dynamic(new Document([ 'sessions' => $sessions, 'total' => count($sessions), @@ -528,6 +483,7 @@ App::get('/v1/users/:userId/memberships') ->desc('Get User Memberships') ->groups(['api', 'users']) ->label('scope', 'users.read') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'getMemberships') @@ -567,6 +523,7 @@ App::get('/v1/users/:userId/logs') ->desc('Get User Logs') ->groups(['api', 'users']) ->label('scope', 'users.read') + ->label('usage.metric', 'users.{scope}.requests.read') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'getLogs') @@ -639,8 +596,6 @@ App::get('/v1/users/:userId/logs') } } - $usage->setParam('users.read', 1); - $response->dynamic(new Document([ 'total' => $audit->countLogsByUser($user->getId()), 'logs' => $output, @@ -654,6 +609,7 @@ App::patch('/v1/users/:userId/status') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'updateStatus') @@ -665,9 +621,8 @@ App::patch('/v1/users/:userId/status') ->param('status', null, new Boolean(true), 'User Status. To activate the user pass `true` and to block the user pass `false`.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, bool $status, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, bool $status, Response $response, Database $dbForProject, Event $events) { $user = $dbForProject->getDocument('users', $userId); @@ -677,9 +632,8 @@ App::patch('/v1/users/:userId/status') $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('status', (bool) $status)); - $usage->setParam('users.update', 1); - - $events->setParam('userId', $user->getId()); + $events + ->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_USER); }); @@ -690,6 +644,7 @@ App::patch('/v1/users/:userId/verification') ->label('event', 'users.[userId].update.verification') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'updateEmailVerification') @@ -701,9 +656,8 @@ App::patch('/v1/users/:userId/verification') ->param('emailVerification', false, new Boolean(), 'User email verification status.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, bool $emailVerification, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, bool $emailVerification, Response $response, Database $dbForProject, Event $events) { $user = $dbForProject->getDocument('users', $userId); @@ -713,9 +667,8 @@ App::patch('/v1/users/:userId/verification') $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', $emailVerification)); - $usage->setParam('users.update', 1); - - $events->setParam('userId', $user->getId()); + $events + ->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_USER); }); @@ -726,6 +679,7 @@ App::patch('/v1/users/:userId/verification/phone') ->label('event', 'users.[userId].update.verification') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'updatePhoneVerification') @@ -737,9 +691,8 @@ App::patch('/v1/users/:userId/verification/phone') ->param('phoneVerification', false, new Boolean(), 'User phone verification status.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, bool $phoneVerification, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, bool $phoneVerification, Response $response, Database $dbForProject, Event $events) { $user = $dbForProject->getDocument('users', $userId); @@ -749,9 +702,8 @@ App::patch('/v1/users/:userId/verification/phone') $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('phoneVerification', $phoneVerification)); - $usage->setParam('users.update', 1); - - $events->setParam('userId', $user->getId()); + $events + ->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_USER); }); @@ -763,6 +715,7 @@ App::patch('/v1/users/:userId/name') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'updateName') @@ -785,8 +738,7 @@ App::patch('/v1/users/:userId/name') $user ->setAttribute('name', $name) - ->setAttribute('search', \implode(' ', [$user->getId(), $user->getAttribute('email', ''), $name, $user->getAttribute('phone', '')])); - ; + ->setAttribute('search', \implode(' ', [$user->getId(), $user->getAttribute('email', ''), $name, $user->getAttribute('phone', '')]));; $user = $dbForProject->updateDocument('users', $user->getId(), $user); @@ -802,6 +754,7 @@ App::patch('/v1/users/:userId/password') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'updatePassword') @@ -842,6 +795,7 @@ App::patch('/v1/users/:userId/email') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') ->label('audits.userId', '{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'updateEmail') @@ -867,8 +821,7 @@ App::patch('/v1/users/:userId/email') $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) - ->setAttribute('search', \implode(' ', [$user->getId(), $email, $user->getAttribute('name', ''), $user->getAttribute('phone', '')])) - ; + ->setAttribute('search', \implode(' ', [$user->getId(), $email, $user->getAttribute('name', ''), $user->getAttribute('phone', '')])); try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); @@ -887,6 +840,7 @@ App::patch('/v1/users/:userId/phone') ->label('event', 'users.[userId].update.phone') ->label('scope', 'users.write') ->label('audits.resource', 'user/{response.$id}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'updatePhone') @@ -910,8 +864,7 @@ App::patch('/v1/users/:userId/phone') $user ->setAttribute('phone', $number) ->setAttribute('phoneVerification', false) - ->setAttribute('search', implode(' ', [$user->getId(), $user->getAttribute('name', ''), $user->getAttribute('email', ''), $number])); - ; + ->setAttribute('search', implode(' ', [$user->getId(), $user->getAttribute('name', ''), $user->getAttribute('email', ''), $number]));; try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); @@ -931,6 +884,7 @@ App::patch('/v1/users/:userId/verification') ->label('scope', 'users.write') ->label('audits.resource', 'user/{request.userId}') ->label('audits.userId', '{request.userId}') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'updateEmailVerification') @@ -942,9 +896,8 @@ App::patch('/v1/users/:userId/verification') ->param('emailVerification', false, new Boolean(), 'User email verification status.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, bool $emailVerification, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, bool $emailVerification, Response $response, Database $dbForProject, Event $events) { $user = $dbForProject->getDocument('users', $userId); @@ -954,8 +907,6 @@ App::patch('/v1/users/:userId/verification') $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', $emailVerification)); - $usage->setParam('users.update', 1); - $events->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_USER); @@ -966,6 +917,7 @@ App::patch('/v1/users/:userId/prefs') ->groups(['api', 'users']) ->label('event', 'users.[userId].update.prefs') ->label('scope', 'users.write') + ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'updatePrefs') @@ -977,9 +929,8 @@ App::patch('/v1/users/:userId/prefs') ->param('prefs', '', new Assoc(), 'Prefs key-value JSON object.') ->inject('response') ->inject('dbForProject') - ->inject('usage') ->inject('events') - ->action(function (string $userId, array $prefs, Response $response, Database $dbForProject, Stats $usage, Event $events) { + ->action(function (string $userId, array $prefs, Response $response, Database $dbForProject, Event $events) { $user = $dbForProject->getDocument('users', $userId); @@ -989,9 +940,8 @@ App::patch('/v1/users/:userId/prefs') $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('prefs', $prefs)); - $usage->setParam('users.update', 1); - - $events->setParam('userId', $user->getId()); + $events + ->setParam('userId', $user->getId()); $response->dynamic(new Document($prefs), Response::MODEL_PREFERENCES); }); @@ -1002,6 +952,7 @@ App::delete('/v1/users/:userId/sessions/:sessionId') ->label('event', 'users.[userId].sessions.[sessionId].delete') ->label('scope', 'users.write') ->label('audits.resource', 'user/{request.userId}') + ->label('usage.metric', 'sessions.{scope}.requests.delete') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'deleteSession') @@ -1013,8 +964,7 @@ App::delete('/v1/users/:userId/sessions/:sessionId') ->inject('response') ->inject('dbForProject') ->inject('events') - ->inject('usage') - ->action(function (string $userId, string $sessionId, Response $response, Database $dbForProject, Event $events, Stats $usage) { + ->action(function (string $userId, string $sessionId, Response $response, Database $dbForProject, Event $events) { $user = $dbForProject->getDocument('users', $userId); @@ -1031,16 +981,9 @@ App::delete('/v1/users/:userId/sessions/:sessionId') $dbForProject->deleteDocument('sessions', $session->getId()); $dbForProject->deleteCachedDocument('users', $user->getId()); - - $usage - ->setParam('users.update', 1) - ->setParam('users.sessions.delete', 1) - ; - $events ->setParam('userId', $user->getId()) - ->setParam('sessionId', $sessionId) - ; + ->setParam('sessionId', $sessionId); $response->noContent(); }); @@ -1051,6 +994,7 @@ App::delete('/v1/users/:userId/sessions') ->label('event', 'users.[userId].sessions.[sessionId].delete') ->label('scope', 'users.write') ->label('audits.resource', 'user/{user.$id}') + ->label('usage.metric', 'sessions.{scope}.requests.delete') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'deleteSessions') @@ -1061,8 +1005,7 @@ App::delete('/v1/users/:userId/sessions') ->inject('response') ->inject('dbForProject') ->inject('events') - ->inject('usage') - ->action(function (string $userId, Response $response, Database $dbForProject, Event $events, Stats $usage) { + ->action(function (string $userId, Response $response, Database $dbForProject, Event $events) { $user = $dbForProject->getDocument('users', $userId); @@ -1072,7 +1015,8 @@ App::delete('/v1/users/:userId/sessions') $sessions = $user->getAttribute('sessions', []); - foreach ($sessions as $key => $session) { /** @var Document $session */ + foreach ($sessions as $key => $session) { + /** @var Document $session */ $dbForProject->deleteDocument('sessions', $session->getId()); //TODO: fix this } @@ -1081,13 +1025,7 @@ App::delete('/v1/users/:userId/sessions') $events ->setParam('userId', $user->getId()) - ->setPayload($response->output($user, Response::MODEL_USER)) - ; - - $usage - ->setParam('users.update', 1) - ->setParam('users.sessions.delete', 1) - ; + ->setPayload($response->output($user, Response::MODEL_USER)); $response->noContent(); }); @@ -1098,6 +1036,7 @@ App::delete('/v1/users/:userId') ->label('event', 'users.[userId].delete') ->label('scope', 'users.write') ->label('audits.resource', 'user/{request.userId}') + ->label('usage.metric', 'users.{scope}.requests.delete') ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) ->label('sdk.namespace', 'users') ->label('sdk.method', 'delete') @@ -1109,8 +1048,7 @@ App::delete('/v1/users/:userId') ->inject('dbForProject') ->inject('events') ->inject('deletes') - ->inject('usage') - ->action(function (string $userId, Response $response, Database $dbForProject, Event $events, Delete $deletes, Stats $usage) { + ->action(function (string $userId, Response $response, Database $dbForProject, Event $events, Delete $deletes) { $user = $dbForProject->getDocument('users', $userId); @@ -1125,15 +1063,11 @@ App::delete('/v1/users/:userId') $deletes ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($clone) - ; + ->setDocument($clone); $events ->setParam('userId', $user->getId()) - ->setPayload($response->output($clone, Response::MODEL_USER)) - ; - - $usage->setParam('users.delete', 1); + ->setPayload($response->output($clone, Response::MODEL_USER)); $response->noContent(); }); @@ -1149,7 +1083,7 @@ App::get('/v1/users/usage') ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_USAGE_USERS) ->param('range', '30d', new WhiteList(['24h', '7d', '30d', '90d'], true), 'Date range.', true) - ->param('provider', '', new WhiteList(\array_merge(['email', 'anonymous'], \array_map(fn($value) => "oauth-" . $value, \array_keys(Config::getParam('providers', [])))), true), 'Provider Name.', true) + ->param('provider', '', new WhiteList(\array_merge(['email', 'anonymous'], \array_map(fn ($value) => "oauth-" . $value, \array_keys(Config::getParam('providers', [])))), true), 'Provider Name.', true) ->inject('response') ->inject('dbForProject') ->inject('register') @@ -1177,14 +1111,14 @@ App::get('/v1/users/usage') ]; $metrics = [ - "users.count", - "users.create", - "users.read", - "users.update", - "users.delete", - "users.sessions.create", - "users.sessions.$provider.create", - "users.sessions.delete" + 'users.$all.requests.count', + 'users.$all.requests.create', + 'users.$all.requests.read', + 'users.$all.requests.update', + 'users.$all.requests.delete', + 'sessions.$all.requests.create', + 'sessions.$all.requests.delete', + "sessions.$provider.requests.create", ]; $stats = []; @@ -1229,14 +1163,14 @@ App::get('/v1/users/usage') $usage = new Document([ 'range' => $range, - 'usersCount' => $stats["users.count"], - 'usersCreate' => $stats["users.create"], - 'usersRead' => $stats["users.read"], - 'usersUpdate' => $stats["users.update"], - 'usersDelete' => $stats["users.delete"], - 'sessionsCreate' => $stats["users.sessions.create"], - 'sessionsProviderCreate' => $stats["users.sessions.$provider.create"], - 'sessionsDelete' => $stats["users.sessions.delete"] + 'usersCount' => $stats['users.$all.requests.count'] ?? [], + 'usersCreate' => $stats['users.$all.requests.create'] ?? [], + 'usersRead' => $stats['users.$all.requests.read'] ?? [], + 'usersUpdate' => $stats['users.$all.requests.update'] ?? [], + 'usersDelete' => $stats['users.$all.requests.delete'] ?? [], + 'sessionsCreate' => $stats['sessions.$all.requests.create'] ?? [], + 'sessionsProviderCreate' => $stats["sessions.$provider.requests.create"] ?? [], + 'sessionsDelete' => $stats['sessions.$all.requests.delete' ?? []] ]); } diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 031ea948d8..6014e320ba 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -7,7 +7,7 @@ use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Mail; use Appwrite\Messaging\Adapter\Realtime; -use Appwrite\Stats\Stats; +use Appwrite\Usage\Stats; use Appwrite\Utopia\Response; use Appwrite\Utopia\Request; use Utopia\App; @@ -145,13 +145,10 @@ App::init() $usage ->setParam('projectId', $project->getId()) - ->setParam('httpRequest', 1) - ->setParam('httpUrl', $request->getHostname() . $request->getURI()) + ->setParam('project.{scope}.network.requests', 1) ->setParam('httpMethod', $request->getMethod()) - ->setParam('httpPath', $route->getPath()) - ->setParam('networkRequestSize', 0) - ->setParam('networkResponseSize', 0) - ->setParam('storage', 0); + ->setParam('project.{scope}.network.inbound', 0) + ->setParam('project.{scope}.network.outbound', 0); $deletes->setProject($project); $database->setProject($project); @@ -285,7 +282,7 @@ App::shutdown() $bucket = $events->getContext('bucket'); $target = Realtime::fromPayload( - // Pass first, most verbose event pattern + // Pass first, most verbose event pattern event: $allEvents[0], payload: $payload, project: $project, @@ -402,10 +399,31 @@ App::shutdown() && $project->getId() && $mode !== APP_MODE_ADMIN // TODO: add check to make sure user is admin && !empty($route->getLabel('sdk.namespace', null)) - ) { + ) { // Don't calculate console usage on admin mode + $metric = $route->getLabel('usage.metric', ''); + $usageParams = $route->getLabel('usage.params', ''); + + if (!empty($metric)) { + $usage->setParam($metric, 1); + foreach ($usageParams as $param) { + $param = $parseLabel($param, $responsePayload, $requestParams, $user); + $parts = explode(':', $param); + if (count($parts) != 2) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Usage params not properly set'); + } + $usage->setParam($parts[0], $parts[1]); + } + } + + $fileSize = 0; + $file = $request->getFiles('file'); + if (!empty($file)) { + $fileSize = (\is_array($file['size']) && isset($file['size'][0])) ? $file['size'][0] : $file['size']; + } + $usage - ->setParam('networkRequestSize', $request->getSize() + $usage->getParam('storage')) - ->setParam('networkResponseSize', $response->getSize()) - ->submit(); + ->setParam('project.{scope}.network.inbound', $request->getSize() + $fileSize) + ->setParam('project.{scope}.network.outbound', $response->getSize()) + ->submit(); } }); diff --git a/app/http.php b/app/http.php index 509908148d..d99771be4a 100644 --- a/app/http.php +++ b/app/http.php @@ -173,6 +173,7 @@ $http->on('start', function (Server $http) use ($payloadSize, $register) { 'antivirus' => true, 'fileSecurity' => true, '$permissions' => [ + Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), diff --git a/app/init.php b/app/init.php index 82d255f55d..093e7ebc5c 100644 --- a/app/init.php +++ b/app/init.php @@ -40,7 +40,7 @@ use Appwrite\Network\Validator\Email; use Appwrite\Network\Validator\IP; use Appwrite\Network\Validator\URL; use Appwrite\OpenSSL\OpenSSL; -use Appwrite\Stats\Stats; +use Appwrite\Usage\Stats; use Appwrite\Utopia\View; use Utopia\App; use Utopia\Database\ID; diff --git a/app/tasks/maintenance.php b/app/tasks/maintenance.php index c0fa920787..dffc67c4cb 100644 --- a/app/tasks/maintenance.php +++ b/app/tasks/maintenance.php @@ -134,7 +134,7 @@ $cli (new Delete()) ->setType(DELETE_TYPE_CACHE_BY_TIMESTAMP) - ->setTimestamp(time() - $interval) + ->setDatetime(DateTime::addSeconds(new \DateTime(), -1 * $interval)) ->trigger(); } diff --git a/app/tasks/usage.php b/app/tasks/usage.php index 7a22d7e79f..48876557a8 100644 --- a/app/tasks/usage.php +++ b/app/tasks/usage.php @@ -4,21 +4,25 @@ global $cli, $register; use Appwrite\Stats\Usage; use Appwrite\Stats\UsageDB; +use Appwrite\Usage\Calculators\Aggregator; +use Appwrite\Usage\Calculators\Database; +use Appwrite\Usage\Calculators\TimeSeries; use InfluxDB\Database as InfluxDatabase; use Utopia\App; use Utopia\Cache\Adapter\Redis as RedisCache; use Utopia\Cache\Cache; use Utopia\CLI\Console; use Utopia\Database\Adapter\MariaDB; -use Utopia\Database\Database; +use Utopia\Database\Database as UtopiaDatabase; use Utopia\Database\Validator\Authorization; use Utopia\Registry\Registry; use Utopia\Logger\Log; +use Utopia\Validator\WhiteList; Authorization::disable(); Authorization::setDefaultStatus(false); -function getDatabase(Registry &$register, string $namespace): Database +function getDatabase(Registry &$register, string $namespace): UtopiaDatabase { $attempts = 0; @@ -30,7 +34,7 @@ function getDatabase(Registry &$register, string $namespace): Database $redis = $register->get('cache'); $cache = new Cache(new RedisCache($redis)); - $database = new Database(new MariaDB($db), $cache); + $database = new UtopiaDatabase(new MariaDB($db), $cache); $database->setDefaultDatabase(App::getEnv('_APP_DB_SCHEMA', 'appwrite')); $database->setNamespace($namespace); @@ -38,7 +42,7 @@ function getDatabase(Registry &$register, string $namespace): Database throw new Exception('Projects collection not ready'); } break; // leave loop if successful - } catch (\Exception$e) { + } catch (\Exception $e) { Console::warning("Database not ready. Retrying connection ({$attempts})..."); if ($attempts >= DATABASE_RECONNECT_MAX_ATTEMPTS) { throw new \Exception('Failed to connect to database: ' . $e->getMessage()); @@ -65,7 +69,7 @@ function getInfluxDB(Registry &$register): InfluxDatabase if (in_array('telegraf', $client->listDatabases())) { break; // leave the do-while if successful } - } catch (\Throwable$th) { + } catch (\Throwable $th) { Console::warning("InfluxDB not ready. Retrying connection ({$attempts})..."); if ($attempts >= $max) { throw new \Exception('InfluxDB database not ready yet'); @@ -110,55 +114,63 @@ $logError = function (Throwable $error, string $action = 'syncUsageStats') use ( Console::warning($error->getTraceAsString()); }; + +function aggregateTimeseries(UtopiaDatabase $database, InfluxDatabase $influxDB, callable $logError): void +{ + $interval = (int) App::getEnv('_APP_USAGE_TIMESERIES_INTERVAL', '30'); // 30 seconds (by default) + $usage = new TimeSeries($database, $influxDB, $logError); + + Console::loop(function () use ($interval, $usage) { + $now = date('d-m-Y H:i:s', time()); + Console::info("[{$now}] Aggregating Timeseries Usage data every {$interval} seconds"); + $loopStart = microtime(true); + + $usage->collect(); + + $loopTook = microtime(true) - $loopStart; + $now = date('d-m-Y H:i:s', time()); + Console::info("[{$now}] Aggregation took {$loopTook} seconds"); + }, $interval); +} + +function aggregateDatabase(UtopiaDatabase $database, callable $logError): void +{ + $interval = (int) App::getEnv('_APP_USAGE_DATABASE_INTERVAL', '900'); // 15 minutes (by default) + $usage = new Database($database, $logError); + $aggregrator = new Aggregator($database, $logError); + + Console::loop(function () use ($interval, $usage, $aggregrator) { + $now = date('d-m-Y H:i:s', time()); + Console::info("[{$now}] Aggregating database usage every {$interval} seconds."); + $loopStart = microtime(true); + $usage->collect(); + $aggregrator->collect(); + $loopTook = microtime(true) - $loopStart; + $now = date('d-m-Y H:i:s', time()); + + Console::info("[{$now}] Aggregation took {$loopTook} seconds"); + }, $interval); +} + $cli ->task('usage') + ->param('type', 'timeseries', new WhiteList(['timeseries', 'database'])) ->desc('Schedules syncing data from influxdb to Appwrite console db') - ->action(function () use ($register, $logError) { + ->action(function (string $type) use ($register, $logError) { Console::title('Usage Aggregation V1'); Console::success(APP_NAME . ' usage aggregation process v1 has started'); - $interval = (int) App::getEnv('_APP_USAGE_AGGREGATION_INTERVAL', '30'); // 30 seconds (by default) - $database = getDatabase($register, '_console'); $influxDB = getInfluxDB($register); - $usage = new Usage($database, $influxDB, $logError); - $usageDB = new UsageDB($database, $logError); - - $iterations = 0; - Console::loop(function () use ($interval, $usage, $usageDB, &$iterations) { - $now = date('d-m-Y H:i:s', time()); - Console::info("[{$now}] Aggregating usage data every {$interval} seconds"); - - $loopStart = microtime(true); - - /** - * Aggregate InfluxDB every 30 seconds - */ - $usage->collect(); - - if ($iterations % 30 != 0) { // return if 30 iterations has not passed - $iterations++; - $loopTook = microtime(true) - $loopStart; - $now = date('d-m-Y H:i:s', time()); - Console::info("[{$now}] Aggregation took {$loopTook} seconds"); - return; - } - - $iterations = 0; // Reset iterations to prevent overflow when running for long time - /** - * Aggregate MariaDB every 15 minutes - * Some of the queries here might contain full-table scans. - */ - $now = date('d-m-Y H:i:s', time()); - Console::info("[{$now}] Aggregating database counters."); - - $usageDB->collect(); - - $iterations++; - $loopTook = microtime(true) - $loopStart; - $now = date('d-m-Y H:i:s', time()); - - Console::info("[{$now}] Aggregation took {$loopTook} seconds"); - }, $interval); + switch ($type) { + case 'timeseries': + aggregateTimeseries($database, $influxDB, $logError); + break; + case 'database': + aggregateDatabase($database, $logError); + break; + default: + Console::error("Unsupported usage aggregation type"); + } }); diff --git a/app/views/console/functions/function.phtml b/app/views/console/functions/function.phtml index 27d4c58eac..a0d22c0c1d 100644 --- a/app/views/console/functions/function.phtml +++ b/app/views/console/functions/function.phtml @@ -291,7 +291,7 @@ sort($patterns);
  • 90d
    @@ -312,7 +313,7 @@ sort($patterns);
    Monitors
    - +
      -
    • Executions
    • +
    • Executions
    - +
      -
    • CPU Time
    • +
    • CPU Time
    - +
      -
    • Errors
    • +
    • Errors
  • diff --git a/app/views/console/functions/index.phtml b/app/views/console/functions/index.phtml index fb254a5aa1..2c7618fbed 100644 --- a/app/views/console/functions/index.phtml +++ b/app/views/console/functions/index.phtml @@ -1,5 +1,6 @@ getParam('runtimes', []); +$usageStatsEnabled = $this->getParam('usageStatsEnabled', true); ?>

    @@ -136,5 +137,82 @@ $runtimes = $this->getParam('runtimes', []);

    + +
  • + + + + + + + +
    + +
    + + + +
    + +
    + + + +

    Usage

    + +
    +
    +
    +
    + +
    +
    +
    + + + +
    +
    +
    + +
    +
    +
    + + + +
    +
    +
    + +
    +
    +
    + + +
    +
  • + \ No newline at end of file diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 07384fb0b6..0442d8456b 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -550,10 +550,12 @@ services: - _APP_MAINTENANCE_RETENTION_ABUSE - _APP_MAINTENANCE_RETENTION_AUDIT - appwrite-usage: + appwrite-usage-timeseries: image: /: - entrypoint: usage - container_name: appwrite-usage + entrypoint: + - usage + - --type=timeseries + container_name: appwrite-usage-timeseries <<: *x-logging restart: unless-stopped networks: @@ -571,7 +573,40 @@ services: - _APP_DB_PASS - _APP_INFLUXDB_HOST - _APP_INFLUXDB_PORT - - _APP_USAGE_AGGREGATION_INTERVAL + - _APP_USAGE_TIMESERIES_INTERVAL + - _APP_USAGE_DATABASE_INTERVAL + - _APP_REDIS_HOST + - _APP_REDIS_PORT + - _APP_REDIS_USER + - _APP_REDIS_PASS + - _APP_LOGGING_PROVIDER + - _APP_LOGGING_CONFIG + + appwrite-usage-database: + image: /: + entrypoint: + - usage + - --type=database + container_name: appwrite-usage-database + <<: *x-logging + restart: unless-stopped + networks: + - appwrite + depends_on: + - influxdb + - mariadb + environment: + - _APP_ENV + - _APP_OPENSSL_KEY_V1 + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS + - _APP_INFLUXDB_HOST + - _APP_INFLUXDB_PORT + - _APP_USAGE_TIMESERIES_INTERVAL + - _APP_USAGE_DATABASE_INTERVAL - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER diff --git a/app/workers/builds.php b/app/workers/builds.php index aa6070af42..ee73ffd68c 100644 --- a/app/workers/builds.php +++ b/app/workers/builds.php @@ -6,6 +6,7 @@ use Appwrite\Resque\Worker; use Appwrite\Utopia\Response\Model\Deployment; use Cron\CronExpression; use Executor\Executor; +use Appwrite\Usage\Stats; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\App; @@ -214,6 +215,22 @@ class BuildsV1 extends Worker channels: $target['channels'], roles: $target['roles'] ); + + /** Update usage stats */ + global $register; + if (App::getEnv('_APP_USAGE_STATS', 'enabled') === 'enabled') { + $statsd = $register->get('statsd'); + $usage = new Stats($statsd); + $usage + ->setParam('projectId', $project->getId()) + ->setParam('functionId', $function->getId()) + ->setParam('builds.{scope}.compute', 1) + ->setParam('buildStatus', $build->getAttribute('status', '')) + ->setParam('buildTime', $build->getAttribute('duration')) + ->setParam('networkRequestSize', 0) + ->setParam('networkResponseSize', 0) + ->submit(); + } } } diff --git a/app/workers/functions.php b/app/workers/functions.php index f3384187cf..461df8e031 100644 --- a/app/workers/functions.php +++ b/app/workers/functions.php @@ -4,7 +4,7 @@ use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Messaging\Adapter\Realtime; use Appwrite\Resque\Worker; -use Appwrite\Stats\Stats; +use Appwrite\Usage\Stats; use Appwrite\Utopia\Response\Model\Execution; use Cron\CronExpression; use Executor\Executor; @@ -361,9 +361,9 @@ class FunctionsV1 extends Worker $usage ->setParam('projectId', $project->getId()) ->setParam('functionId', $function->getId()) - ->setParam('functionExecution', 1) - ->setParam('functionStatus', $execution->getAttribute('status', '')) - ->setParam('functionExecutionTime', $execution->getAttribute('time') * 1000) // ms + ->setParam('executions.{scope}.compute', 1) + ->setParam('executionStatus', $execution->getAttribute('status', '')) + ->setParam('executionTime', $execution->getAttribute('time')) ->setParam('networkRequestSize', 0) ->setParam('networkResponseSize', 0) ->submit(); diff --git a/composer.json b/composer.json index e90bca3182..40027ab0f7 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "utopia-php/cache": "0.6.*", "utopia-php/cli": "0.13.*", "utopia-php/config": "0.2.*", - "utopia-php/database": "0.23.*", + "utopia-php/database": "dev-refactor-permissions as 0.23.0", "utopia-php/locale": "0.4.*", "utopia-php/registry": "0.5.*", "utopia-php/preloader": "0.2.*", diff --git a/composer.lock b/composer.lock index 2dcac966fe..0da13d2de3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0e850206a924d2a48861ecd290f59bc0", + "content-hash": "64351ec59c6d50023ef9f6195777709b", "packages": [ { "name": "adhocore/jwt", @@ -2052,16 +2052,16 @@ }, { "name": "utopia-php/database", - "version": "0.23.0", + "version": "dev-refactor-permissions", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "e4a23f8e26a3d96f292e549cebe0ff6937ec5d71" + "reference": "44dda6914c7be148eb59ce11847386ce39f7b106" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/e4a23f8e26a3d96f292e549cebe0ff6937ec5d71", - "reference": "e4a23f8e26a3d96f292e549cebe0ff6937ec5d71", + "url": "https://api.github.com/repos/utopia-php/database/zipball/44dda6914c7be148eb59ce11847386ce39f7b106", + "reference": "44dda6914c7be148eb59ce11847386ce39f7b106", "shasum": "" }, "require": { @@ -2110,9 +2110,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/0.23.0" + "source": "https://github.com/utopia-php/database/tree/refactor-permissions" }, - "time": "2022-08-18T19:08:33+00:00" + "time": "2022-08-24T10:22:04+00:00" }, { "name": "utopia-php/domains", @@ -5354,10 +5354,17 @@ "version": "9999999-dev", "alias": "0.19.5", "alias_normalized": "0.19.5.0" + }, + { + "package": "utopia-php/database", + "version": "dev-refactor-permissions", + "alias": "0.23.0", + "alias_normalized": "0.23.0.0" } ], "minimum-stability": "stable", "stability-flags": { + "utopia-php/database": 20, "appwrite/sdk-generator": 20 }, "prefer-stable": false, diff --git a/docker-compose.yml b/docker-compose.yml index 631e7ad592..9dd5b8721f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -582,10 +582,12 @@ services: - _APP_MAINTENANCE_RETENTION_ABUSE - _APP_MAINTENANCE_RETENTION_AUDIT - appwrite-usage: - entrypoint: usage + appwrite-usage-timeseries: + entrypoint: + - usage + - --type=timeseries <<: *x-logging - container_name: appwrite-usage + container_name: appwrite-usage-timeseries build: context: . args: @@ -609,7 +611,46 @@ services: - _APP_DB_PASS - _APP_INFLUXDB_HOST - _APP_INFLUXDB_PORT - - _APP_USAGE_AGGREGATION_INTERVAL + - _APP_USAGE_TIMESERIES_INTERVAL + - _APP_USAGE_DATABASE_INTERVAL + - _APP_REDIS_HOST + - _APP_REDIS_PORT + - _APP_REDIS_USER + - _APP_REDIS_PASS + - _APP_LOGGING_PROVIDER + - _APP_LOGGING_CONFIG + + appwrite-usage-database: + entrypoint: + - usage + - --type=database + <<: *x-logging + container_name: appwrite-usage-database + build: + context: . + args: + - DEBUG=false + networks: + - appwrite + volumes: + - ./app:/usr/src/code/app + - ./src:/usr/src/code/src + - ./dev:/usr/local/dev + depends_on: + - influxdb + - mariadb + environment: + - _APP_ENV + - _APP_OPENSSL_KEY_V1 + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS + - _APP_INFLUXDB_HOST + - _APP_INFLUXDB_PORT + - _APP_USAGE_TIMESERIES_INTERVAL + - _APP_USAGE_DATABASE_INTERVAL - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER diff --git a/public/dist/scripts/app-all.js b/public/dist/scripts/app-all.js index 4a86ef82b6..212c1c5bc6 100644 --- a/public/dist/scripts/app-all.js +++ b/public/dist/scripts/app-all.js @@ -66,9 +66,9 @@ updatePassword(password,oldPassword){return __awaiter(this,void 0,void 0,functio let path='/account/password';let payload={};if(typeof password!=='undefined'){payload['password']=password;} if(typeof oldPassword!=='undefined'){payload['oldPassword']=oldPassword;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('patch',uri,{'content-type':'application/json',},payload);});} -updatePhone(number,password){return __awaiter(this,void 0,void 0,function*(){if(typeof number==='undefined'){throw new AppwriteException('Missing required parameter: "number"');} +updatePhone(phone,password){return __awaiter(this,void 0,void 0,function*(){if(typeof phone==='undefined'){throw new AppwriteException('Missing required parameter: "phone"');} if(typeof password==='undefined'){throw new AppwriteException('Missing required parameter: "password"');} -let path='/account/phone';let payload={};if(typeof number!=='undefined'){payload['number']=number;} +let path='/account/phone';let payload={};if(typeof phone!=='undefined'){payload['phone']=phone;} if(typeof password!=='undefined'){payload['password']=password;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('patch',uri,{'content-type':'application/json',},payload);});} getPrefs(){return __awaiter(this,void 0,void 0,function*(){let path='/account/prefs';let payload={};const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} @@ -115,10 +115,10 @@ if(typeof scopes!=='undefined'){payload['scopes']=scopes;} const uri=new URL(this.client.config.endpoint+path);payload['project']=this.client.config.project;for(const[key,value]of Object.entries(Service.flatten(payload))){uri.searchParams.append(key,value);} if(typeof window!=='undefined'&&(window===null||window===void 0?void 0:window.location)){window.location.href=uri.toString();} else{return uri;}} -createPhoneSession(userId,number){return __awaiter(this,void 0,void 0,function*(){if(typeof userId==='undefined'){throw new AppwriteException('Missing required parameter: "userId"');} -if(typeof number==='undefined'){throw new AppwriteException('Missing required parameter: "number"');} +createPhoneSession(userId,phone){return __awaiter(this,void 0,void 0,function*(){if(typeof userId==='undefined'){throw new AppwriteException('Missing required parameter: "userId"');} +if(typeof phone==='undefined'){throw new AppwriteException('Missing required parameter: "phone"');} let path='/account/sessions/phone';let payload={};if(typeof userId!=='undefined'){payload['userId']=userId;} -if(typeof number!=='undefined'){payload['number']=number;} +if(typeof phone!=='undefined'){payload['phone']=phone;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('post',uri,{'content-type':'application/json',},payload);});} updatePhoneSession(userId,secret){return __awaiter(this,void 0,void 0,function*(){if(typeof userId==='undefined'){throw new AppwriteException('Missing required parameter: "userId"');} if(typeof secret==='undefined'){throw new AppwriteException('Missing required parameter: "secret"');} @@ -442,6 +442,8 @@ if(typeof schedule!=='undefined'){payload['schedule']=schedule;} if(typeof timeout!=='undefined'){payload['timeout']=timeout;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('post',uri,{'content-type':'application/json',},payload);});} listRuntimes(){return __awaiter(this,void 0,void 0,function*(){let path='/functions/runtimes';let payload={};const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} +getUsage(range){return __awaiter(this,void 0,void 0,function*(){let path='/functions/usage';let payload={};if(typeof range!=='undefined'){payload['range']=range;} +const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} get(functionId){return __awaiter(this,void 0,void 0,function*(){if(typeof functionId==='undefined'){throw new AppwriteException('Missing required parameter: "functionId"');} let path='/functions/{functionId}'.replace('{functionId}',functionId);let payload={};const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} update(functionId,name,execute,vars,events,schedule,timeout){return __awaiter(this,void 0,void 0,function*(){if(typeof functionId==='undefined'){throw new AppwriteException('Missing required parameter: "functionId"');} @@ -504,7 +506,7 @@ const uri=new URL(this.client.config.endpoint+path);return yield this.client.cal getExecution(functionId,executionId){return __awaiter(this,void 0,void 0,function*(){if(typeof functionId==='undefined'){throw new AppwriteException('Missing required parameter: "functionId"');} if(typeof executionId==='undefined'){throw new AppwriteException('Missing required parameter: "executionId"');} let path='/functions/{functionId}/executions/{executionId}'.replace('{functionId}',functionId).replace('{executionId}',executionId);let payload={};const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} -getUsage(functionId,range){return __awaiter(this,void 0,void 0,function*(){if(typeof functionId==='undefined'){throw new AppwriteException('Missing required parameter: "functionId"');} +getFunctionUsage(functionId,range){return __awaiter(this,void 0,void 0,function*(){if(typeof functionId==='undefined'){throw new AppwriteException('Missing required parameter: "functionId"');} let path='/functions/{functionId}/usage'.replace('{functionId}',functionId);let payload={};if(typeof range!=='undefined'){payload['range']=range;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});}} class Health extends Service{constructor(client){super(client);} @@ -858,11 +860,10 @@ if(typeof cursor!=='undefined'){payload['cursor']=cursor;} if(typeof cursorDirection!=='undefined'){payload['cursorDirection']=cursorDirection;} if(typeof orderType!=='undefined'){payload['orderType']=orderType;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} -create(userId,email,password,name){return __awaiter(this,void 0,void 0,function*(){if(typeof userId==='undefined'){throw new AppwriteException('Missing required parameter: "userId"');} -if(typeof email==='undefined'){throw new AppwriteException('Missing required parameter: "email"');} -if(typeof password==='undefined'){throw new AppwriteException('Missing required parameter: "password"');} +create(userId,email,phone,password,name){return __awaiter(this,void 0,void 0,function*(){if(typeof userId==='undefined'){throw new AppwriteException('Missing required parameter: "userId"');} let path='/users';let payload={};if(typeof userId!=='undefined'){payload['userId']=userId;} if(typeof email!=='undefined'){payload['email']=email;} +if(typeof phone!=='undefined'){payload['phone']=phone;} if(typeof password!=='undefined'){payload['password']=password;} if(typeof name!=='undefined'){payload['name']=name;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('post',uri,{'content-type':'application/json',},payload);});} @@ -993,12 +994,91 @@ updatePhoneVerification(userId,phoneVerification){return __awaiter(this,void 0,v if(typeof phoneVerification==='undefined'){throw new AppwriteException('Missing required parameter: "phoneVerification"');} let path='/users/{userId}/verification/phone'.replace('{userId}',userId);let payload={};if(typeof phoneVerification!=='undefined'){payload['phoneVerification']=phoneVerification;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('patch',uri,{'content-type':'application/json',},payload);});}} -exports.Account=Account;exports.AppwriteException=AppwriteException;exports.Avatars=Avatars;exports.Client=Client;exports.Databases=Databases;exports.Functions=Functions;exports.Health=Health;exports.Locale=Locale;exports.Projects=Projects;exports.Query=Query;exports.Storage=Storage;exports.Teams=Teams;exports.Users=Users;Object.defineProperty(exports,'__esModule',{value:true});})(this.Appwrite=this.Appwrite||{},null,window);(function(global,factory){typeof exports==='object'&&typeof module!=='undefined'?module.exports=factory():typeof define==='function'&&define.amd?define(factory):(global=typeof globalThis!=='undefined'?globalThis:global||self,global.Chart=factory());})(this,(function(){'use strict';function fontString(pixelSize,fontStyle,fontFamily){return fontStyle+' '+pixelSize+'px '+fontFamily;} +exports.Account=Account;exports.AppwriteException=AppwriteException;exports.Avatars=Avatars;exports.Client=Client;exports.Databases=Databases;exports.Functions=Functions;exports.Health=Health;exports.Locale=Locale;exports.Projects=Projects;exports.Query=Query;exports.Storage=Storage;exports.Teams=Teams;exports.Users=Users;Object.defineProperty(exports,'__esModule',{value:true});})(this.Appwrite=this.Appwrite||{},null,window);(function(global,factory){typeof exports==='object'&&typeof module!=='undefined'?module.exports=factory():typeof define==='function'&&define.amd?define(factory):(global=typeof globalThis!=='undefined'?globalThis:global||self,global.Chart=factory());})(this,(function(){'use strict';function noop(){} +const uid=(function(){let id=0;return function(){return id++;};}());function isNullOrUndef(value){return value===null||typeof value==='undefined';} +function isArray(value){if(Array.isArray&&Array.isArray(value)){return true;} +const type=Object.prototype.toString.call(value);if(type.slice(0,7)==='[object'&&type.slice(-6)==='Array]'){return true;} +return false;} +function isObject(value){return value!==null&&Object.prototype.toString.call(value)==='[object Object]';} +const isNumberFinite=(value)=>(typeof value==='number'||value instanceof Number)&&isFinite(+value);function finiteOrDefault(value,defaultValue){return isNumberFinite(value)?value:defaultValue;} +function valueOrDefault(value,defaultValue){return typeof value==='undefined'?defaultValue:value;} +const toPercentage=(value,dimension)=>typeof value==='string'&&value.endsWith('%')?parseFloat(value)/100:value/dimension;const toDimension=(value,dimension)=>typeof value==='string'&&value.endsWith('%')?parseFloat(value)/100*dimension:+value;function callback(fn,args,thisArg){if(fn&&typeof fn.call==='function'){return fn.apply(thisArg,args);}} +function each(loopable,fn,thisArg,reverse){let i,len,keys;if(isArray(loopable)){len=loopable.length;if(reverse){for(i=len-1;i>=0;i--){fn.call(thisArg,loopable[i],i);}}else{for(i=0;iv,x:o=>o.x,y:o=>o.y};function resolveObjectKey(obj,key){const resolver=keyResolvers[key]||(keyResolvers[key]=_getKeyResolver(key));return resolver(obj);} +function _getKeyResolver(key){const keys=_splitKey(key);return obj=>{for(const k of keys){if(k===''){break;} +obj=obj&&obj[k];} +return obj;};} +function _splitKey(key){const parts=key.split('.');const keys=[];let tmp='';for(const part of parts){tmp+=part;if(tmp.endsWith('\\')){tmp=tmp.slice(0,-1)+'.';}else{keys.push(tmp);tmp='';}} +return keys;} +function _capitalize(str){return str.charAt(0).toUpperCase()+str.slice(1);} +const defined=(value)=>typeof value!=='undefined';const isFunction=(value)=>typeof value==='function';const setsEqual=(a,b)=>{if(a.size!==b.size){return false;} +for(const item of a){if(!b.has(item)){return false;}} +return true;};function _isClickEvent(e){return e.type==='mouseup'||e.type==='click'||e.type==='contextmenu';} +const PI=Math.PI;const TAU=2*PI;const PITAU=TAU+PI;const INFINITY=Number.POSITIVE_INFINITY;const RAD_PER_DEG=PI/180;const HALF_PI=PI/2;const QUARTER_PI=PI/4;const TWO_THIRDS_PI=PI*2/3;const log10=Math.log10;const sign=Math.sign;function niceNum(range){const roundedRange=Math.round(range);range=almostEquals(range,roundedRange,range/1000)?roundedRange:range;const niceRange=Math.pow(10,Math.floor(log10(range)));const fraction=range/niceRange;const niceFraction=fraction<=1?1:fraction<=2?2:fraction<=5?5:10;return niceFraction*niceRange;} +function _factorize(value){const result=[];const sqrt=Math.sqrt(value);let i;for(i=1;ia-b).pop();return result;} +function isNumber(n){return!isNaN(parseFloat(n))&&isFinite(n);} +function almostEquals(x,y,epsilon){return Math.abs(x-y)=x);} +function _setMinAndMaxByKey(array,target,property){let i,ilen,value;for(i=0,ilen=array.length;iangleToEnd&&startToAngle=Math.min(start,end)-epsilon&&value<=Math.max(start,end)+epsilon;} +function _lookup(table,value,cmp){cmp=cmp||((index)=>table[index]1){mid=(lo+hi)>>1;if(cmp(mid)){lo=mid;}else{hi=mid;}} +return{lo,hi};} +const _lookupByKey=(table,key,value,last)=>_lookup(table,value,last?index=>table[index][key]<=value:index=>table[index][key]_lookup(table,value,index=>table[index][key]>=value);function _filterBetween(values,min,max){let start=0;let end=values.length;while(startstart&&values[end-1]>max){end--;} +return start>0||end{const method='_onData'+_capitalize(key);const base=array[key];Object.defineProperty(array,key,{configurable:true,enumerable:false,value(...args){const res=base.apply(this,args);array._chartjs.listeners.forEach((object)=>{if(typeof object[method]==='function'){object[method](...args);}});return res;}});});} +function unlistenArrayEvents(array,listener){const stub=array._chartjs;if(!stub){return;} +const listeners=stub.listeners;const index=listeners.indexOf(listener);if(index!==-1){listeners.splice(index,1);} +if(listeners.length>0){return;} +arrayEvents.forEach((key)=>{delete array[key];});delete array._chartjs;} +function _arrayUnique(items){const set=new Set();let i,ilen;for(i=0,ilen=items.length;iArray.prototype.slice.call(args));let ticking=false;let args=[];return function(...rest){args=updateArgs(rest);if(!ticking){ticking=true;requestAnimFrame.call(window,()=>{ticking=false;fn.apply(thisArg,args);});}};} function debounce(fn,delay){let timeout;return function(...args){if(delay){clearTimeout(timeout);timeout=setTimeout(fn,delay,args);}else{fn.apply(this,args);} return delay;};} -const _toLeftRightCenter=(align)=>align==='start'?'left':align==='end'?'right':'center';const _alignStartEnd=(align,start,end)=>align==='start'?start:align==='end'?end:(start+end)/2;const _textX=(align,left,right,rtl)=>{const check=rtl?'left':'right';return align===check?right:align==='center'?(left+right)/2:left;};class Animator{constructor(){this._request=null;this._charts=new Map();this._running=false;this._lastDate=undefined;} +const _toLeftRightCenter=(align)=>align==='start'?'left':align==='end'?'right':'center';const _alignStartEnd=(align,start,end)=>align==='start'?start:align==='end'?end:(start+end)/2;const _textX=(align,left,right,rtl)=>{const check=rtl?'left':'right';return align===check?right:align==='center'?(left+right)/2:left;};function _getStartAndCountOfVisiblePoints(meta,points,animationsDisabled){const pointCount=points.length;let start=0;let count=pointCount;if(meta._sorted){const{iScale,_parsed}=meta;const axis=iScale.axis;const{min,max,minDefined,maxDefined}=iScale.getUserBounds();if(minDefined){start=_limitValue(Math.min(_lookupByKey(_parsed,iScale.axis,min).lo,animationsDisabled?pointCount:_lookupByKey(points,axis,iScale.getPixelForValue(min)).lo),0,pointCount-1);} +if(maxDefined){count=_limitValue(Math.max(_lookupByKey(_parsed,iScale.axis,max,true).hi+1,animationsDisabled?0:_lookupByKey(points,axis,iScale.getPixelForValue(max),true).hi+1),start,pointCount)-start;}else{count=pointCount-start;}} +return{start,count};} +function _scaleRangesChanged(meta){const{xScale,yScale,_scaleRanges}=meta;const newRanges={xmin:xScale.min,xmax:xScale.max,ymin:yScale.min,ymax:yScale.max};if(!_scaleRanges){meta._scaleRanges=newRanges;return true;} +const changed=_scaleRanges.xmin!==xScale.min||_scaleRanges.xmax!==xScale.max||_scaleRanges.ymin!==yScale.min||_scaleRanges.ymax!==yScale.max;Object.assign(_scaleRanges,newRanges);return changed;} +class Animator{constructor(){this._request=null;this._charts=new Map();this._running=false;this._lastDate=undefined;} _notify(chart,anims,date,type){const callbacks=anims.listeners[type];const numSteps=anims.duration;callbacks.forEach(fn=>fn({chart,initial:anims.initial,numSteps,currentStep:Math.min(date-anims.start,numSteps)}));} _refresh(){if(this._request){return;} this._running=true;this._request=requestAnimFrame.call(window,()=>{this._update();this._request=null;if(this._running){this._refresh();}});} @@ -1064,8 +1144,8 @@ r=+m[1];g=+m[3];b=+m[5];r=255&(m[2]?p2b(r):lim(r,0,255));g=255&(m[4]?p2b(g):lim( function rgbString(v){return v&&(v.a<255?`rgba(${v.r}, ${v.g}, ${v.b}, ${b2n(v.a)})`:`rgb(${v.r}, ${v.g}, ${v.b})`);} const to=v=>v<=0.0031308?v*12.92:Math.pow(v,1.0/2.4)*1.055-0.055;const from=v=>v<=0.04045?v/12.92:Math.pow((v+0.055)/1.055,2.4);function interpolate$1(rgb1,rgb2,t){const r=from(b2n(rgb1.r));const g=from(b2n(rgb1.g));const b=from(b2n(rgb1.b));return{r:n2b(to(r+t*(from(b2n(rgb2.r))-r))),g:n2b(to(g+t*(from(b2n(rgb2.g))-g))),b:n2b(to(b+t*(from(b2n(rgb2.b))-b))),a:rgb1.a+t*(rgb2.a-rgb1.a)};} function modHSL(v,i,ratio){if(v){let tmp=rgb2hsl(v);tmp[i]=Math.max(0,Math.min(tmp[i]+tmp[i]*ratio,i===0?360:1));tmp=hsl2rgb(tmp);v.r=tmp[0];v.g=tmp[1];v.b=tmp[2];}} -function clone$1(v,proto){return v?Object.assign(proto||{},v):v;} -function fromObject(input){var v={r:0,g:0,b:0,a:255};if(Array.isArray(input)){if(input.length>=3){v={r:input[0],g:input[1],b:input[2],a:255};if(input.length>3){v.a=n2b(input[3]);}}}else{v=clone$1(input,{r:0,g:0,b:0,a:1});v.a=n2b(v.a);} +function clone(v,proto){return v?Object.assign(proto||{},v):v;} +function fromObject(input){var v={r:0,g:0,b:0,a:255};if(Array.isArray(input)){if(input.length>=3){v={r:input[0],g:input[1],b:input[2],a:255};if(input.length>3){v.a=n2b(input[3]);}}}else{v=clone(input,{r:0,g:0,b:0,a:1});v.a=n2b(v.a);} return v;} function functionParse(str){if(str.charAt(0)==='r'){return rgbParse(str);} return hueParse(str);} @@ -1073,7 +1153,7 @@ class Color{constructor(input){if(input instanceof Color){return input;} const type=typeof input;let v;if(type==='object'){v=fromObject(input);}else if(type==='string'){v=hexParse(input)||nameParse(input)||functionParse(input);} this._rgb=v;this._valid=!!v;} get valid(){return this._valid;} -get rgb(){var v=clone$1(this._rgb);if(v){v.a=b2n(v.a);} +get rgb(){var v=clone(this._rgb);if(v){v.a=b2n(v.a);} return v;} set rgb(obj){this._rgb=fromObject(obj);} rgbString(){return this._valid?rgbString(this._rgb):undefined;} @@ -1099,42 +1179,6 @@ function isPatternOrGradient(value){if(value&&typeof value==='object'){const typ return false;} function color(value){return isPatternOrGradient(value)?value:index_esm(value);} function getHoverColor(value){return isPatternOrGradient(value)?value:index_esm(value).saturate(0.5).darken(0.1).hexString();} -function noop(){} -const uid=(function(){let id=0;return function(){return id++;};}());function isNullOrUndef(value){return value===null||typeof value==='undefined';} -function isArray(value){if(Array.isArray&&Array.isArray(value)){return true;} -const type=Object.prototype.toString.call(value);if(type.slice(0,7)==='[object'&&type.slice(-6)==='Array]'){return true;} -return false;} -function isObject(value){return value!==null&&Object.prototype.toString.call(value)==='[object Object]';} -const isNumberFinite=(value)=>(typeof value==='number'||value instanceof Number)&&isFinite(+value);function finiteOrDefault(value,defaultValue){return isNumberFinite(value)?value:defaultValue;} -function valueOrDefault(value,defaultValue){return typeof value==='undefined'?defaultValue:value;} -const toPercentage=(value,dimension)=>typeof value==='string'&&value.endsWith('%')?parseFloat(value)/100:value/dimension;const toDimension=(value,dimension)=>typeof value==='string'&&value.endsWith('%')?parseFloat(value)/100*dimension:+value;function callback(fn,args,thisArg){if(fn&&typeof fn.call==='function'){return fn.apply(thisArg,args);}} -function each(loopable,fn,thisArg,reverse){let i,len,keys;if(isArray(loopable)){len=loopable.length;if(reverse){for(i=len-1;i>=0;i--){fn.call(thisArg,loopable[i],i);}}else{for(i=0;ipos){obj=obj[key.slice(pos,idx)];pos=idx+1;idx=indexOfDotOrLength(key,pos);} -return obj;} -function _capitalize(str){return str.charAt(0).toUpperCase()+str.slice(1);} -const defined=(value)=>typeof value!=='undefined';const isFunction=(value)=>typeof value==='function';const setsEqual=(a,b)=>{if(a.size!==b.size){return false;} -for(const item of a){if(!b.has(item)){return false;}} -return true;};function _isClickEvent(e){return e.type==='mouseup'||e.type==='click'||e.type==='contextmenu';} const overrides=Object.create(null);const descriptors=Object.create(null);function getScope$1(node,key){if(!key){return node;} const keys=key.split('.');for(let i=0,n=keys.length;i!name.startsWith('on'),_indexable:(name)=>name!=='events',hover:{_fallback:'interaction'},interaction:{_scriptable:false,_indexable:false,}});function _lookup(table,value,cmp){cmp=cmp||((index)=>table[index]1){mid=(lo+hi)>>1;if(cmp(mid)){lo=mid;}else{hi=mid;}} -return{lo,hi};} -const _lookupByKey=(table,key,value)=>_lookup(table,value,index=>table[index][key]_lookup(table,value,index=>table[index][key]>=value);function _filterBetween(values,min,max){let start=0;let end=values.length;while(startstart&&values[end-1]>max){end--;} -return start>0||end{const method='_onData'+_capitalize(key);const base=array[key];Object.defineProperty(array,key,{configurable:true,enumerable:false,value(...args){const res=base.apply(this,args);array._chartjs.listeners.forEach((object)=>{if(typeof object[method]==='function'){object[method](...args);}});return res;}});});} -function unlistenArrayEvents(array,listener){const stub=array._chartjs;if(!stub){return;} -const listeners=stub.listeners;const index=listeners.indexOf(listener);if(index!==-1){listeners.splice(index,1);} -if(listeners.length>0){return;} -arrayEvents.forEach((key)=>{delete array[key];});delete array._chartjs;} -function _arrayUnique(items){const set=new Set();let i,ilen;for(i=0,ilen=items.length;ia-b).pop();return result;} -function isNumber(n){return!isNaN(parseFloat(n))&&isFinite(n);} -function almostEquals(x,y,epsilon){return Math.abs(x-y)=x);} -function _setMinAndMaxByKey(array,target,property){let i,ilen,value;for(i=0,ilen=array.length;iangleToEnd&&startToAngle=Math.min(start,end)-epsilon&&value<=Math.max(start,end)+epsilon;} -function _isDomSupported(){return typeof window!=='undefined'&&typeof document!=='undefined';} +var defaults=new Defaults({_scriptable:(name)=>!name.startsWith('on'),_indexable:(name)=>name!=='events',hover:{_fallback:'interaction'},interaction:{_scriptable:false,_indexable:false,}});function _isDomSupported(){return typeof window!=='undefined'&&typeof document!=='undefined';} function _getParentNode(domNode){let parent=domNode.parentNode;if(parent&&parent.toString()==='[object ShadowRoot]'){parent=parent.host;} return parent;} function parseMaxStyle(styleValue,node,parentProperty){let valueInPixels;if(typeof styleValue==='string'){valueInPixels=parseInt(styleValue,10);if(styleValue.indexOf('%')!==-1){valueInPixels=valueInPixels/100*node.parentNode[parentProperty];}}else{valueInPixels=styleValue;} @@ -1376,7 +1384,7 @@ if(start0;} _computeGridLineItems(chartArea){const axis=this.axis;const chart=this.chart;const options=this.options;const{grid,position}=options;const offset=grid.offset;const isHorizontal=this.isHorizontal();const ticks=this.ticks;const ticksLength=ticks.length+(offset?1:0);const tl=getTickMarkLength(grid);const items=[];const borderOpts=grid.setContext(this.getContext());const axisWidth=borderOpts.drawBorder?borderOpts.borderWidth:0;const axisHalfWidth=axisWidth/2;const alignBorderValue=function(pixel){return _alignPixel(chart,pixel,axisWidth);};let borderValue,i,lineValue,alignedLineValue;let tx1,ty1,tx2,ty2,x1,y1,x2,y2;if(position==='top'){borderValue=alignBorderValue(this.bottom);ty1=this.bottom-tl;ty2=borderValue-axisHalfWidth;y1=alignBorderValue(chartArea.top)+axisHalfWidth;y2=chartArea.bottom;}else if(position==='bottom'){borderValue=alignBorderValue(this.top);y1=chartArea.top;y2=alignBorderValue(chartArea.bottom)-axisHalfWidth;ty1=borderValue+axisHalfWidth;ty2=this.top+tl;}else if(position==='left'){borderValue=alignBorderValue(this.right);tx1=this.right-tl;tx2=borderValue-axisHalfWidth;x1=alignBorderValue(chartArea.left)+axisHalfWidth;x2=chartArea.right;}else if(position==='right'){borderValue=alignBorderValue(this.left);x1=chartArea.left;x2=alignBorderValue(chartArea.right)-axisHalfWidth;tx1=borderValue+axisHalfWidth;tx2=this.left+tl;}else if(axis==='x'){if(position==='center'){borderValue=alignBorderValue((chartArea.top+chartArea.bottom)/2+0.5);}else if(isObject(position)){const positionAxisID=Object.keys(position)[0];const value=position[positionAxisID];borderValue=alignBorderValue(this.chart.scales[positionAxisID].getPixelForValue(value));} y1=chartArea.top;y2=chartArea.bottom;ty1=borderValue+axisHalfWidth;ty2=ty1+tl;}else if(axis==='y'){if(position==='center'){borderValue=alignBorderValue((chartArea.left+chartArea.right)/2);}else if(isObject(position)){const positionAxisID=Object.keys(position)[0];const value=position[positionAxisID];borderValue=alignBorderValue(this.chart.scales[positionAxisID].getPixelForValue(value));} tx1=borderValue-axisHalfWidth;tx2=tx1-tl;x1=chartArea.left;x2=chartArea.right;} -const limit=valueOrDefault(options.ticks.maxTicksLimit,ticksLength);const step=Math.max(1,Math.ceil(ticksLength/limit));for(i=0;iisObject(value)&&Object.getOwnPropertyNames(value).reduce((acc,key)=>acc||isFunction(value[key]),false);function needContext(proxy,names){const{isScriptable,isIndexable}=_descriptors(proxy);for(const prop of names){const scriptable=isScriptable(prop);const indexable=isIndexable(prop);const value=(indexable||scriptable)&&proxy[prop];if((scriptable&&(isFunction(value)||hasFunction(value)))||(indexable&&isArray(value))){return true;}} return false;} -var version="3.8.2";const KNOWN_POSITIONS=['top','bottom','left','right','chartArea'];function positionIsHorizontal(position,axis){return position==='top'||position==='bottom'||(KNOWN_POSITIONS.indexOf(position)===-1&&axis==='x');} +var version="3.9.1";const KNOWN_POSITIONS=['top','bottom','left','right','chartArea'];function positionIsHorizontal(position,axis){return position==='top'||position==='bottom'||(KNOWN_POSITIONS.indexOf(position)===-1&&axis==='x');} function compare2Level(l1,l2){return function(a,b){return a[l1]===b[l1]?a[l2]-b[l2]:a[l1]-b[l1];};} function onAnimationsComplete(context){const chart=context.chart;const animationOptions=chart.options.animation;chart.notifyPlugins('afterRender');callback(animationOptions&&animationOptions.onComplete,[context],chart);} function onAnimationProgress(context){const chart=context.chart;const animationOptions=chart.options.animation;callback(animationOptions&&animationOptions.onProgress,[context],chart);} @@ -2028,6 +2036,7 @@ if(!inChartArea){return lastActive;} const hoverOptions=this.options.hover;return this.getElementsAtEventForMode(e,hoverOptions.mode,hoverOptions,useFinalPosition);}} const invalidatePlugins=()=>each(Chart.instances,(chart)=>chart._plugins.invalidate());const enumerable=true;Object.defineProperties(Chart,{defaults:{enumerable,value:defaults},instances:{enumerable,value:instances},overrides:{enumerable,value:overrides},registry:{enumerable,value:registry},version:{enumerable,value:version},getChart:{enumerable,value:getChart},register:{enumerable,value:(...items)=>{registry.add(...items);invalidatePlugins();}},unregister:{enumerable,value:(...items)=>{registry.remove(...items);invalidatePlugins();}}});function abstract(){throw new Error('This method is not implemented: Check that a complete date adapter is provided.');} class DateAdapter{constructor(options){this.options=options||{};} +init(chartOptions){} formats(){return abstract();} parse(value,format){return abstract();} format(timestamp,format){return abstract();} @@ -2061,6 +2070,7 @@ function borderProps(properties){let reverse,start,end,top,bottom;if(properties. if(reverse){top='end';bottom='start';}else{top='start';bottom='end';} return{start,end,reverse,top,bottom};} function setBorderSkipped(properties,options,stack,index){let edge=options.borderSkipped;const res={};if(!edge){properties.borderSkipped=res;return;} +if(edge===true){properties.borderSkipped={top:true,right:true,bottom:true,left:true};return;} const{start,end,reverse,top,bottom}=borderProps(properties);if(edge==='middle'&&stack){properties.enableBorderRadius=true;if((stack._top||0)===index){edge=top;}else if((stack._bottom||0)===index){edge=bottom;}else{res[parseEdge(bottom,start,end,reverse)]=true;edge=top;}} res[parseEdge(edge,start,end,reverse)]=true;properties.borderSkipped=res;} function parseEdge(edge,a,b,reverse){if(reverse){edge=swap(edge,a,b);edge=startEnd(edge,b,a);}else{edge=startEnd(edge,a,b);} @@ -2149,7 +2159,7 @@ _getVisibleDatasetWeightTotal(){return this._getRingWeightOffset(this.chart.data DoughnutController.id='doughnut';DoughnutController.defaults={datasetElementType:false,dataElementType:'arc',animation:{animateRotate:true,animateScale:false},animations:{numbers:{type:'number',properties:['circumference','endAngle','innerRadius','outerRadius','startAngle','x','y','offset','borderWidth','spacing']},},cutout:'50%',rotation:0,circumference:360,radius:'100%',spacing:0,indexAxis:'r',};DoughnutController.descriptors={_scriptable:(name)=>name!=='spacing',_indexable:(name)=>name!=='spacing',};DoughnutController.overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(chart){const data=chart.data;if(data.labels.length&&data.datasets.length){const{labels:{pointStyle}}=chart.legend.options;return data.labels.map((label,i)=>{const meta=chart.getDatasetMeta(0);const style=meta.controller.getStyle(i);return{text:label,fillStyle:style.backgroundColor,strokeStyle:style.borderColor,lineWidth:style.borderWidth,pointStyle:pointStyle,hidden:!chart.getDataVisibility(i),index:i};});} return[];}},onClick(e,legendItem,legend){legend.chart.toggleDataVisibility(legendItem.index);legend.chart.update();}},tooltip:{callbacks:{title(){return'';},label(tooltipItem){let dataLabel=tooltipItem.label;const value=': '+tooltipItem.formattedValue;if(isArray(dataLabel)){dataLabel=dataLabel.slice();dataLabel[0]+=value;}else{dataLabel+=value;} return dataLabel;}}}}};class LineController extends DatasetController{initialize(){this.enableOptionSharing=true;this.supportsDecimation=true;super.initialize();} -update(mode){const meta=this._cachedMeta;const{dataset:line,data:points=[],_dataset}=meta;const animationsDisabled=this.chart._animationsDisabled;let{start,count}=getStartAndCountOfVisiblePoints(meta,points,animationsDisabled);this._drawStart=start;this._drawCount=count;if(scaleRangesChanged(meta)){start=0;count=points.length;} +update(mode){const meta=this._cachedMeta;const{dataset:line,data:points=[],_dataset}=meta;const animationsDisabled=this.chart._animationsDisabled;let{start,count}=_getStartAndCountOfVisiblePoints(meta,points,animationsDisabled);this._drawStart=start;this._drawCount=count;if(_scaleRangesChanged(meta)){start=0;count=points.length;} line._chart=this.chart;line._datasetIndex=this.index;line._decimated=!!_dataset._decimated;line.points=points;const options=this.resolveDatasetElementOptions(mode);if(!this.options.showLine){options.borderWidth=0;} options.segment=this.options.segment;this.updateElement(line,undefined,{animated:!animationsDisabled,options},mode);this.updateElements(points,start,count,mode);} updateElements(points,start,count,mode){const reset=mode==='reset';const{iScale,vScale,_stacked,_dataset}=this._cachedMeta;const{sharedOptions,includeOptions}=this._getSharedOptions(start,mode);const iAxis=iScale.axis;const vAxis=vScale.axis;const{spanGaps,segment}=this.options;const maxGapLength=isNumber(spanGaps)?spanGaps:Number.POSITIVE_INFINITY;const directUpdate=this.chart._animationsDisabled||reset||mode==='none';let prevParsed=start>0&&this.getParsed(start-1);for(let i=start;i0&&(Math.abs(parsed[iAxis]-prevParsed[iAxis]))>maxGapLength;if(segment){properties.parsed=parsed;properties.raw=_dataset.data[i];} @@ -2159,12 +2169,7 @@ prevParsed=parsed;}} getMaxOverflow(){const meta=this._cachedMeta;const dataset=meta.dataset;const border=dataset.options&&dataset.options.borderWidth||0;const data=meta.data||[];if(!data.length){return border;} const firstPoint=data[0].size(this.resolveDataElementOptions(0));const lastPoint=data[data.length-1].size(this.resolveDataElementOptions(data.length-1));return Math.max(border,firstPoint,lastPoint)/2;} draw(){const meta=this._cachedMeta;meta.dataset.updateControlPoints(this.chart.chartArea,meta.iScale.axis);super.draw();}} -LineController.id='line';LineController.defaults={datasetElementType:'line',dataElementType:'point',showLine:true,spanGaps:false,};LineController.overrides={scales:{_index_:{type:'category',},_value_:{type:'linear',},}};function getStartAndCountOfVisiblePoints(meta,points,animationsDisabled){const pointCount=points.length;let start=0;let count=pointCount;if(meta._sorted){const{iScale,_parsed}=meta;const axis=iScale.axis;const{min,max,minDefined,maxDefined}=iScale.getUserBounds();if(minDefined){start=_limitValue(Math.min(_lookupByKey(_parsed,iScale.axis,min).lo,animationsDisabled?pointCount:_lookupByKey(points,axis,iScale.getPixelForValue(min)).lo),0,pointCount-1);} -if(maxDefined){count=_limitValue(Math.max(_lookupByKey(_parsed,iScale.axis,max).hi+1,animationsDisabled?0:_lookupByKey(points,axis,iScale.getPixelForValue(max)).hi+1),start,pointCount)-start;}else{count=pointCount-start;}} -return{start,count};} -function scaleRangesChanged(meta){const{xScale,yScale,_scaleRanges}=meta;const newRanges={xmin:xScale.min,xmax:xScale.max,ymin:yScale.min,ymax:yScale.max};if(!_scaleRanges){meta._scaleRanges=newRanges;return true;} -const changed=_scaleRanges.xmin!==xScale.min||_scaleRanges.xmax!==xScale.max||_scaleRanges.ymin!==yScale.min||_scaleRanges.ymax!==yScale.max;Object.assign(_scaleRanges,newRanges);return changed;} -class PolarAreaController extends DatasetController{constructor(chart,datasetIndex){super(chart,datasetIndex);this.innerRadius=undefined;this.outerRadius=undefined;} +LineController.id='line';LineController.defaults={datasetElementType:'line',dataElementType:'point',showLine:true,spanGaps:false,};LineController.overrides={scales:{_index_:{type:'category',},_value_:{type:'linear',},}};class PolarAreaController extends DatasetController{constructor(chart,datasetIndex){super(chart,datasetIndex);this.innerRadius=undefined;this.outerRadius=undefined;} getLabelAndValue(index){const meta=this._cachedMeta;const chart=this.chart;const labels=chart.data.labels||[];const value=formatNumber(meta._parsed[index].r,chart.options.locale);return{label:labels[index]||'',value,};} parseObjectData(meta,data,start,count){return _parseObjectDataRadialScale.bind(this)(meta,data,start,count);} update(mode){const arcs=this._cachedMeta.data;this._updateRadius();this.updateElements(arcs,0,arcs.length,mode);} @@ -2185,37 +2190,50 @@ update(mode){const meta=this._cachedMeta;const line=meta.dataset;const points=me const properties={_loop:true,_fullLoop:labels.length===points.length,options};this.updateElement(line,undefined,properties,mode);} this.updateElements(points,0,points.length,mode);} updateElements(points,start,count,mode){const scale=this._cachedMeta.rScale;const reset=mode==='reset';for(let i=start;ipixelMargin){angleMargin=pixelMargin/innerRadius;ctx.arc(x,y,innerRadius,endAngle+angleMargin,startAngle-angleMargin,true);}else{ctx.arc(x,y,pixelMargin,endAngle+HALF_PI,startAngle-HALF_PI);} +RadarController.id='radar';RadarController.defaults={datasetElementType:'line',dataElementType:'point',indexAxis:'r',showLine:true,elements:{line:{fill:'start'}},};RadarController.overrides={aspectRatio:1,scales:{r:{type:'radialLinear',}}};class ScatterController extends DatasetController{update(mode){const meta=this._cachedMeta;const{data:points=[]}=meta;const animationsDisabled=this.chart._animationsDisabled;let{start,count}=_getStartAndCountOfVisiblePoints(meta,points,animationsDisabled);this._drawStart=start;this._drawCount=count;if(_scaleRangesChanged(meta)){start=0;count=points.length;} +if(this.options.showLine){const{dataset:line,_dataset}=meta;line._chart=this.chart;line._datasetIndex=this.index;line._decimated=!!_dataset._decimated;line.points=points;const options=this.resolveDatasetElementOptions(mode);options.segment=this.options.segment;this.updateElement(line,undefined,{animated:!animationsDisabled,options},mode);} +this.updateElements(points,start,count,mode);} +addElements(){const{showLine}=this.options;if(!this.datasetElementType&&showLine){this.datasetElementType=registry.getElement('line');} +super.addElements();} +updateElements(points,start,count,mode){const reset=mode==='reset';const{iScale,vScale,_stacked,_dataset}=this._cachedMeta;const firstOpts=this.resolveDataElementOptions(start,mode);const sharedOptions=this.getSharedOptions(firstOpts);const includeOptions=this.includeOptions(mode,sharedOptions);const iAxis=iScale.axis;const vAxis=vScale.axis;const{spanGaps,segment}=this.options;const maxGapLength=isNumber(spanGaps)?spanGaps:Number.POSITIVE_INFINITY;const directUpdate=this.chart._animationsDisabled||reset||mode==='none';let prevParsed=start>0&&this.getParsed(start-1);for(let i=start;i0&&(Math.abs(parsed[iAxis]-prevParsed[iAxis]))>maxGapLength;if(segment){properties.parsed=parsed;properties.raw=_dataset.data[i];} +if(includeOptions){properties.options=sharedOptions||this.resolveDataElementOptions(i,point.active?'active':mode);} +if(!directUpdate){this.updateElement(point,i,properties,mode);} +prevParsed=parsed;} +this.updateSharedOptions(sharedOptions,mode,firstOpts);} +getMaxOverflow(){const meta=this._cachedMeta;const data=meta.data||[];if(!this.options.showLine){let max=0;for(let i=data.length-1;i>=0;--i){max=Math.max(max,data[i].size(this.resolveDataElementOptions(i))/2);} +return max>0&&max;} +const dataset=meta.dataset;const border=dataset.options&&dataset.options.borderWidth||0;if(!data.length){return border;} +const firstPoint=data[0].size(this.resolveDataElementOptions(0));const lastPoint=data[data.length-1].size(this.resolveDataElementOptions(data.length-1));return Math.max(border,firstPoint,lastPoint)/2;}} +ScatterController.id='scatter';ScatterController.defaults={datasetElementType:false,dataElementType:'point',showLine:false,fill:false};ScatterController.overrides={interaction:{mode:'point'},plugins:{tooltip:{callbacks:{title(){return'';},label(item){return'('+item.label+', '+item.formattedValue+')';}}}},scales:{x:{type:'linear'},y:{type:'linear'}}};var controllers=Object.freeze({__proto__:null,BarController:BarController,BubbleController:BubbleController,DoughnutController:DoughnutController,LineController:LineController,PolarAreaController:PolarAreaController,PieController:PieController,RadarController:RadarController,ScatterController:ScatterController});function clipArc(ctx,element,endAngle){const{startAngle,pixelMargin,x,y,outerRadius,innerRadius}=element;let angleMargin=pixelMargin/outerRadius;ctx.beginPath();ctx.arc(x,y,outerRadius,startAngle-angleMargin,endAngle+angleMargin);if(innerRadius>pixelMargin){angleMargin=pixelMargin/innerRadius;ctx.arc(x,y,innerRadius,endAngle+angleMargin,startAngle-angleMargin,true);}else{ctx.arc(x,y,pixelMargin,endAngle+HALF_PI,startAngle-HALF_PI);} ctx.closePath();ctx.clip();} function toRadiusCorners(value){return _readValueToProps(value,['outerStart','outerEnd','innerStart','innerEnd']);} function parseBorderRadius$1(arc,innerRadius,outerRadius,angleDelta){const o=toRadiusCorners(arc.options.borderRadius);const halfThickness=(outerRadius-innerRadius)/2;const innerLimit=Math.min(halfThickness,angleDelta*innerRadius/2);const computeOuterLimit=(val)=>{const outerArcLimit=(outerRadius-Math.min(halfThickness,val))*angleDelta/2;return _limitValue(val,0,Math.min(halfThickness,outerArcLimit));};return{outerStart:computeOuterLimit(o.outerStart),outerEnd:computeOuterLimit(o.outerEnd),innerStart:_limitValue(o.innerStart,0,innerLimit),innerEnd:_limitValue(o.innerEnd,0,innerLimit),};} function rThetaToXY(r,theta,x,y){return{x:x+r*Math.cos(theta),y:y+r*Math.sin(theta),};} -function pathArc(ctx,element,offset,spacing,end){const{x,y,startAngle:start,pixelMargin,innerRadius:innerR}=element;const outerRadius=Math.max(element.outerRadius+spacing+offset-pixelMargin,0);const innerRadius=innerR>0?innerR+spacing+offset+pixelMargin:0;let spacingOffset=0;const alpha=end-start;if(spacing){const noSpacingInnerRadius=innerR>0?innerR-spacing:0;const noSpacingOuterRadius=outerRadius>0?outerRadius-spacing:0;const avNogSpacingRadius=(noSpacingInnerRadius+noSpacingOuterRadius)/2;const adjustedAngle=avNogSpacingRadius!==0?(alpha*avNogSpacingRadius)/(avNogSpacingRadius+spacing):alpha;spacingOffset=(alpha-adjustedAngle)/2;} -const beta=Math.max(0.001,alpha*outerRadius-offset/PI)/outerRadius;const angleOffset=(alpha-beta)/2;const startAngle=start+angleOffset+spacingOffset;const endAngle=end-angleOffset-spacingOffset;const{outerStart,outerEnd,innerStart,innerEnd}=parseBorderRadius$1(element,innerRadius,outerRadius,endAngle-startAngle);const outerStartAdjustedRadius=outerRadius-outerStart;const outerEndAdjustedRadius=outerRadius-outerEnd;const outerStartAdjustedAngle=startAngle+outerStart/outerStartAdjustedRadius;const outerEndAdjustedAngle=endAngle-outerEnd/outerEndAdjustedRadius;const innerStartAdjustedRadius=innerRadius+innerStart;const innerEndAdjustedRadius=innerRadius+innerEnd;const innerStartAdjustedAngle=startAngle+innerStart/innerStartAdjustedRadius;const innerEndAdjustedAngle=endAngle-innerEnd/innerEndAdjustedRadius;ctx.beginPath();ctx.arc(x,y,outerRadius,outerStartAdjustedAngle,outerEndAdjustedAngle);if(outerEnd>0){const pCenter=rThetaToXY(outerEndAdjustedRadius,outerEndAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,outerEnd,outerEndAdjustedAngle,endAngle+HALF_PI);} +function pathArc(ctx,element,offset,spacing,end,circular){const{x,y,startAngle:start,pixelMargin,innerRadius:innerR}=element;const outerRadius=Math.max(element.outerRadius+spacing+offset-pixelMargin,0);const innerRadius=innerR>0?innerR+spacing+offset+pixelMargin:0;let spacingOffset=0;const alpha=end-start;if(spacing){const noSpacingInnerRadius=innerR>0?innerR-spacing:0;const noSpacingOuterRadius=outerRadius>0?outerRadius-spacing:0;const avNogSpacingRadius=(noSpacingInnerRadius+noSpacingOuterRadius)/2;const adjustedAngle=avNogSpacingRadius!==0?(alpha*avNogSpacingRadius)/(avNogSpacingRadius+spacing):alpha;spacingOffset=(alpha-adjustedAngle)/2;} +const beta=Math.max(0.001,alpha*outerRadius-offset/PI)/outerRadius;const angleOffset=(alpha-beta)/2;const startAngle=start+angleOffset+spacingOffset;const endAngle=end-angleOffset-spacingOffset;const{outerStart,outerEnd,innerStart,innerEnd}=parseBorderRadius$1(element,innerRadius,outerRadius,endAngle-startAngle);const outerStartAdjustedRadius=outerRadius-outerStart;const outerEndAdjustedRadius=outerRadius-outerEnd;const outerStartAdjustedAngle=startAngle+outerStart/outerStartAdjustedRadius;const outerEndAdjustedAngle=endAngle-outerEnd/outerEndAdjustedRadius;const innerStartAdjustedRadius=innerRadius+innerStart;const innerEndAdjustedRadius=innerRadius+innerEnd;const innerStartAdjustedAngle=startAngle+innerStart/innerStartAdjustedRadius;const innerEndAdjustedAngle=endAngle-innerEnd/innerEndAdjustedRadius;ctx.beginPath();if(circular){ctx.arc(x,y,outerRadius,outerStartAdjustedAngle,outerEndAdjustedAngle);if(outerEnd>0){const pCenter=rThetaToXY(outerEndAdjustedRadius,outerEndAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,outerEnd,outerEndAdjustedAngle,endAngle+HALF_PI);} const p4=rThetaToXY(innerEndAdjustedRadius,endAngle,x,y);ctx.lineTo(p4.x,p4.y);if(innerEnd>0){const pCenter=rThetaToXY(innerEndAdjustedRadius,innerEndAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,innerEnd,endAngle+HALF_PI,innerEndAdjustedAngle+Math.PI);} ctx.arc(x,y,innerRadius,endAngle-(innerEnd/innerRadius),startAngle+(innerStart/innerRadius),true);if(innerStart>0){const pCenter=rThetaToXY(innerStartAdjustedRadius,innerStartAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,innerStart,innerStartAdjustedAngle+Math.PI,startAngle-HALF_PI);} -const p8=rThetaToXY(outerStartAdjustedRadius,startAngle,x,y);ctx.lineTo(p8.x,p8.y);if(outerStart>0){const pCenter=rThetaToXY(outerStartAdjustedRadius,outerStartAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,outerStart,startAngle-HALF_PI,outerStartAdjustedAngle);} +const p8=rThetaToXY(outerStartAdjustedRadius,startAngle,x,y);ctx.lineTo(p8.x,p8.y);if(outerStart>0){const pCenter=rThetaToXY(outerStartAdjustedRadius,outerStartAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,outerStart,startAngle-HALF_PI,outerStartAdjustedAngle);}}else{ctx.moveTo(x,y);const outerStartX=Math.cos(outerStartAdjustedAngle)*outerRadius+x;const outerStartY=Math.sin(outerStartAdjustedAngle)*outerRadius+y;ctx.lineTo(outerStartX,outerStartY);const outerEndX=Math.cos(outerEndAdjustedAngle)*outerRadius+x;const outerEndY=Math.sin(outerEndAdjustedAngle)*outerRadius+y;ctx.lineTo(outerEndX,outerEndY);} ctx.closePath();} -function drawArc(ctx,element,offset,spacing){const{fullCircles,startAngle,circumference}=element;let endAngle=element.endAngle;if(fullCircles){pathArc(ctx,element,offset,spacing,startAngle+TAU);for(let i=0;i=TAU||_angleBetween(angle,startAngle,endAngle);const withinRadius=_isBetween(distance,innerRadius+rAdjust,outerRadius+rAdjust);return(betweenAngles&&withinRadius);} getCenterPoint(useFinalPosition){const{x,y,startAngle,endAngle,innerRadius,outerRadius}=this.getProps(['x','y','startAngle','endAngle','innerRadius','outerRadius','circumference',],useFinalPosition);const{offset,spacing}=this.options;const halfAngle=(startAngle+endAngle)/2;const halfRadius=(innerRadius+outerRadius+spacing+offset)/2;return{x:x+Math.cos(halfAngle)*halfRadius,y:y+Math.sin(halfAngle)*halfRadius};} tooltipPosition(useFinalPosition){return this.getCenterPoint(useFinalPosition);} -draw(ctx){const{options,circumference}=this;const offset=(options.offset||0)/2;const spacing=(options.spacing||0)/2;this.pixelMargin=(options.borderAlign==='inner')?0.33:0;this.fullCircles=circumference>TAU?Math.floor(circumference/TAU):0;if(circumference===0||this.innerRadius<0||this.outerRadius<0){return;} +draw(ctx){const{options,circumference}=this;const offset=(options.offset||0)/2;const spacing=(options.spacing||0)/2;const circular=options.circular;this.pixelMargin=(options.borderAlign==='inner')?0.33:0;this.fullCircles=circumference>TAU?Math.floor(circumference/TAU):0;if(circumference===0||this.innerRadius<0||this.outerRadius<0){return;} ctx.save();let radiusOffset=0;if(offset){radiusOffset=offset/2;const halfAngle=(this.startAngle+this.endAngle)/2;ctx.translate(Math.cos(halfAngle)*radiusOffset,Math.sin(halfAngle)*radiusOffset);if(this.circumference>=PI){radiusOffset=offset;}} -ctx.fillStyle=options.backgroundColor;ctx.strokeStyle=options.borderColor;const endAngle=drawArc(ctx,this,radiusOffset,spacing);drawBorder(ctx,this,radiusOffset,spacing,endAngle);ctx.restore();}} -ArcElement.id='arc';ArcElement.defaults={borderAlign:'center',borderColor:'#fff',borderJoinStyle:undefined,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:undefined,};ArcElement.defaultRoutes={backgroundColor:'backgroundColor'};function setStyle(ctx,options,style=options){ctx.lineCap=valueOrDefault(style.borderCapStyle,options.borderCapStyle);ctx.setLineDash(valueOrDefault(style.borderDash,options.borderDash));ctx.lineDashOffset=valueOrDefault(style.borderDashOffset,options.borderDashOffset);ctx.lineJoin=valueOrDefault(style.borderJoinStyle,options.borderJoinStyle);ctx.lineWidth=valueOrDefault(style.borderWidth,options.borderWidth);ctx.strokeStyle=valueOrDefault(style.borderColor,options.borderColor);} +ctx.fillStyle=options.backgroundColor;ctx.strokeStyle=options.borderColor;const endAngle=drawArc(ctx,this,radiusOffset,spacing,circular);drawBorder(ctx,this,radiusOffset,spacing,endAngle,circular);ctx.restore();}} +ArcElement.id='arc';ArcElement.defaults={borderAlign:'center',borderColor:'#fff',borderJoinStyle:undefined,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:undefined,circular:true,};ArcElement.defaultRoutes={backgroundColor:'backgroundColor'};function setStyle(ctx,options,style=options){ctx.lineCap=valueOrDefault(style.borderCapStyle,options.borderCapStyle);ctx.setLineDash(valueOrDefault(style.borderDash,options.borderDash));ctx.lineDashOffset=valueOrDefault(style.borderDashOffset,options.borderDashOffset);ctx.lineJoin=valueOrDefault(style.borderJoinStyle,options.borderJoinStyle);ctx.lineWidth=valueOrDefault(style.borderWidth,options.borderWidth);ctx.strokeStyle=valueOrDefault(style.borderColor,options.borderColor);} function lineTo(ctx,previous,target){ctx.lineTo(target.x,target.y);} function getLineMethod(options){if(options.stepped){return _steppedLineTo;} if(options.tension||options.cubicInterpolationMode==='monotone'){return _bezierCurveTo;} @@ -2409,7 +2427,7 @@ hitbox.top=top;hitbox.left+=this.left+padding;hitbox.left=rtlHelper.leftForLtr(r isHorizontal(){return this.options.position==='top'||this.options.position==='bottom';} draw(){if(this.options.display){const ctx=this.ctx;clipArea(ctx,this);this._draw();unclipArea(ctx);}} _draw(){const{options:opts,columnSizes,lineWidths,ctx}=this;const{align,labels:labelOpts}=opts;const defaultColor=defaults.color;const rtlHelper=getRtlAdapter(opts.rtl,this.left,this.width);const labelFont=toFont(labelOpts.font);const{color:fontColor,padding}=labelOpts;const fontSize=labelFont.size;const halfFontSize=fontSize/2;let cursor;this.drawTitle();ctx.textAlign=rtlHelper.textAlign('left');ctx.textBaseline='middle';ctx.lineWidth=0.5;ctx.font=labelFont.string;const{boxWidth,boxHeight,itemHeight}=getBoxSize(labelOpts,fontSize);const drawLegendBox=function(x,y,legendItem){if(isNaN(boxWidth)||boxWidth<=0||isNaN(boxHeight)||boxHeight<0){return;} -ctx.save();const lineWidth=valueOrDefault(legendItem.lineWidth,1);ctx.fillStyle=valueOrDefault(legendItem.fillStyle,defaultColor);ctx.lineCap=valueOrDefault(legendItem.lineCap,'butt');ctx.lineDashOffset=valueOrDefault(legendItem.lineDashOffset,0);ctx.lineJoin=valueOrDefault(legendItem.lineJoin,'miter');ctx.lineWidth=lineWidth;ctx.strokeStyle=valueOrDefault(legendItem.strokeStyle,defaultColor);ctx.setLineDash(valueOrDefault(legendItem.lineDash,[]));if(labelOpts.usePointStyle){const drawOptions={radius:boxHeight*Math.SQRT2/2,pointStyle:legendItem.pointStyle,rotation:legendItem.rotation,borderWidth:lineWidth};const centerX=rtlHelper.xPlus(x,boxWidth/2);const centerY=y+halfFontSize;drawPointLegend(ctx,drawOptions,centerX,centerY,boxWidth);}else{const yBoxTop=y+Math.max((fontSize-boxHeight)/2,0);const xBoxLeft=rtlHelper.leftForLtr(x,boxWidth);const borderRadius=toTRBLCorners(legendItem.borderRadius);ctx.beginPath();if(Object.values(borderRadius).some(v=>v!==0)){addRoundedRectPath(ctx,{x:xBoxLeft,y:yBoxTop,w:boxWidth,h:boxHeight,radius:borderRadius,});}else{ctx.rect(xBoxLeft,yBoxTop,boxWidth,boxHeight);} +ctx.save();const lineWidth=valueOrDefault(legendItem.lineWidth,1);ctx.fillStyle=valueOrDefault(legendItem.fillStyle,defaultColor);ctx.lineCap=valueOrDefault(legendItem.lineCap,'butt');ctx.lineDashOffset=valueOrDefault(legendItem.lineDashOffset,0);ctx.lineJoin=valueOrDefault(legendItem.lineJoin,'miter');ctx.lineWidth=lineWidth;ctx.strokeStyle=valueOrDefault(legendItem.strokeStyle,defaultColor);ctx.setLineDash(valueOrDefault(legendItem.lineDash,[]));if(labelOpts.usePointStyle){const drawOptions={radius:boxHeight*Math.SQRT2/2,pointStyle:legendItem.pointStyle,rotation:legendItem.rotation,borderWidth:lineWidth};const centerX=rtlHelper.xPlus(x,boxWidth/2);const centerY=y+halfFontSize;drawPointLegend(ctx,drawOptions,centerX,centerY,labelOpts.pointStyleWidth&&boxWidth);}else{const yBoxTop=y+Math.max((fontSize-boxHeight)/2,0);const xBoxLeft=rtlHelper.leftForLtr(x,boxWidth);const borderRadius=toTRBLCorners(legendItem.borderRadius);ctx.beginPath();if(Object.values(borderRadius).some(v=>v!==0)){addRoundedRectPath(ctx,{x:xBoxLeft,y:yBoxTop,w:boxWidth,h:boxHeight,radius:borderRadius,});}else{ctx.rect(xBoxLeft,yBoxTop,boxWidth,boxHeight);} ctx.fill();if(lineWidth!==0){ctx.stroke();}} ctx.restore();};const fillText=function(x,y,legendItem){renderText(ctx,legendItem.text,x,y+(itemHeight/2),labelFont,{strikethrough:legendItem.hidden,textAlign:rtlHelper.textAlign(legendItem.textAlign)});};const isHorizontal=this.isHorizontal();const titleHeight=this._computeTitleHeight();if(isHorizontal){cursor={x:_alignStartEnd(align,this.left+padding,this.right-lineWidths[0]),y:this.top+padding+titleHeight,line:0};}else{cursor={x:this.left+padding,y:_alignStartEnd(align,this.top+titleHeight+padding,this.bottom-columnSizes[0].height),line:0};} overrideTextDirection(this.ctx,opts.textDirection);const lineHeight=itemHeight+padding;this.legendItems.forEach((legendItem,i)=>{ctx.strokeStyle=legendItem.fontColor||fontColor;ctx.fillStyle=legendItem.fontColor||fontColor;const textWidth=ctx.measureText(legendItem.text).width;const textAlign=rtlHelper.textAlign(legendItem.textAlign||(legendItem.textAlign=labelOpts.textAlign));const width=boxWidth+halfFontSize+textWidth;let x=cursor.x;let y=cursor.y;rtlHelper.setWidth(this.width);if(isHorizontal){if(i>0&&x+width+padding>this.right){y=cursor.y+=lineHeight;cursor.line++;x=cursor.x=_alignStartEnd(align,this.left+padding,this.right-lineWidths[cursor.line]);}}else if(i>0&&y+lineHeight>this.bottom){x=cursor.x=x+columnSizes[cursor.line].width+padding;cursor.line++;y=cursor.y=_alignStartEnd(align,this.top+titleHeight+padding,this.bottom-columnSizes[cursor.line].height);} @@ -2681,7 +2699,7 @@ return ticks;} function ticksFromTimestamps(scale,values,majorUnit){const ticks=[];const map={};const ilen=values.length;let i,value;for(i=0;i","?",",",".","0","1","2","3","4","5","6","7","8","9"];var isRTL=function(value){for(var i=0;i{return{current:null,history:null,setCurrent:function(currentConnections){var scope=this;scope.current=currentConnections;return scope.current;},setHistory:function(history){var scope=this;scope.history=history;return scope.history;}};},true,true);})(window);window.ls.router.add("/auth/signin",{template:"/auth/signin?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/signup",{template:"/auth/signup?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/recovery",{template:"/auth/recovery?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/recovery/reset",{template:"/auth/recovery/reset?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/confirm",{template:"/auth/confirm?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/join",{template:"/auth/join?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/magic-url",{template:"/auth/magic-url?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/oauth2/success",{template:"/auth/oauth2/success?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/oauth2/failure",{template:"/auth/oauth2/failure?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/console",{template:"/console?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/account",{template:"/console/account?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/account/:tab",{template:"/console/account?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/home",{template:"/console/home?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/home/:tab",{template:"/console/home?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/platforms/:platform",{template:function(window){return window.location.pathname+"?version="+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/notifications",{template:"/console/notifications?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/settings",{template:"/console/settings?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/settings/:tab",{template:"/console/settings?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/webhooks",{template:"/console/webhooks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/webhooks/webhook",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/webhooks/webhook/new",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/keys",{template:"/console/keys?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/keys/:tab",{template:"/console/keys?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/tasks",{template:"/console/tasks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/tasks/:tab",{template:"/console/tasks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/databases",{template:"/console/databases?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/databases/database",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/database/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/usage",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/collection",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/collection/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/document",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/document/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage",{template:"/console/storage?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/storage/usage",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage/bucket",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage/bucket/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/users",{template:"/console/users?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/user",{template:"/console/users/user?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/user/:tab",{template:"/console/users/user?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/teams/team",{template:"/console/users/teams/team?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/teams/team/:tab",{template:"/console/users/teams/team?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/:tab",{template:"/console/users?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions",{template:"/console/functions?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/function",{template:"/console/functions/function?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/function/:tab",{template:"/console/functions/function?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/:tab",{template:"/console/functions?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true});window.ls.filter.add("avatar",function($value,element){if(!$value){return"";} +return false;};return{isRTL:isRTL,};},true);})(window);(function(window){"use strict";window.ls.container.set('sdk',function(window,router){var client=new Appwrite.Client();var endpoint=window.location.origin+'/v1';client.setEndpoint(endpoint).setProject(router.params.project||'').setLocale(APP_ENV.LOCALE).setMode('admin');return{client:client,account:new Appwrite.Account(client),avatars:new Appwrite.Avatars(client),databases:new Appwrite.Databases(client),functions:new Appwrite.Functions(client),health:new Appwrite.Health(client),locale:new Appwrite.Locale(client),storage:new Appwrite.Storage(client),teams:new Appwrite.Teams(client),users:new Appwrite.Users(client)}},false);})(window);(function(window){"use strict";window.ls.container.set('search',function(window){return{params:{},path:'',pointer:'',selected:'',};},true,true);})(window);(function(window){"use strict";window.ls.container.set('timezone',function(){return{convert:function(unixTime){var timezoneMinutes=new Date().getTimezoneOffset();timezoneMinutes=(timezoneMinutes===0)?0:-timezoneMinutes;return parseInt(unixTime)+(timezoneMinutes*60);}};},true);})(window);(function(window){"use strict";window.ls.container.set('realtime',()=>{return{current:null,history:null,setCurrent:function(currentConnections){var scope=this;scope.current=currentConnections;return scope.current;},setHistory:function(history){var scope=this;scope.history=history;return scope.history;}};},true,true);})(window);window.ls.router.add("/auth/signin",{template:"/auth/signin?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/signup",{template:"/auth/signup?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/recovery",{template:"/auth/recovery?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/recovery/reset",{template:"/auth/recovery/reset?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/confirm",{template:"/auth/confirm?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/join",{template:"/auth/join?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/magic-url",{template:"/auth/magic-url?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/oauth2/success",{template:"/auth/oauth2/success?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/oauth2/failure",{template:"/auth/oauth2/failure?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/console",{template:"/console?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/account",{template:"/console/account?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/account/:tab",{template:"/console/account?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/home",{template:"/console/home?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/home/:tab",{template:"/console/home?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/platforms/:platform",{template:function(window){return window.location.pathname+"?version="+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/notifications",{template:"/console/notifications?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/settings",{template:"/console/settings?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/settings/:tab",{template:"/console/settings?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/webhooks",{template:"/console/webhooks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/webhooks/webhook",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/webhooks/webhook/new",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/keys",{template:"/console/keys?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/keys/:tab",{template:"/console/keys?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/tasks",{template:"/console/tasks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/tasks/:tab",{template:"/console/tasks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/databases",{template:"/console/databases?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/databases/database",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/database/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/usage",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/collection",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/collection/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/document",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/document/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage",{template:"/console/storage?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/storage/usage",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage/bucket",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage/bucket/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/users",{template:"/console/users?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/user",{template:"/console/users/user?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/user/:tab",{template:"/console/users/user?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/teams/team",{template:"/console/users/teams/team?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/teams/team/:tab",{template:"/console/users/teams/team?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/:tab",{template:"/console/users?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions",{template:"/console/functions?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/usage",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/functions/function",{template:"/console/functions/function?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/function/:tab",{template:"/console/functions/function?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/:tab",{template:"/console/functions?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true});window.ls.filter.add("avatar",function($value,element){if(!$value){return"";} let size=element.dataset["size"]||80;let name=$value.name||$value||"";name=(typeof name!=='string')?'--':name;return def="/v1/avatars/initials?project=console"+"&name="+ encodeURIComponent(name)+"&width="+ size+"&height="+ diff --git a/public/dist/scripts/app-dep.js b/public/dist/scripts/app-dep.js index 72b39f033a..26c009208e 100644 --- a/public/dist/scripts/app-dep.js +++ b/public/dist/scripts/app-dep.js @@ -66,9 +66,9 @@ updatePassword(password,oldPassword){return __awaiter(this,void 0,void 0,functio let path='/account/password';let payload={};if(typeof password!=='undefined'){payload['password']=password;} if(typeof oldPassword!=='undefined'){payload['oldPassword']=oldPassword;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('patch',uri,{'content-type':'application/json',},payload);});} -updatePhone(number,password){return __awaiter(this,void 0,void 0,function*(){if(typeof number==='undefined'){throw new AppwriteException('Missing required parameter: "number"');} +updatePhone(phone,password){return __awaiter(this,void 0,void 0,function*(){if(typeof phone==='undefined'){throw new AppwriteException('Missing required parameter: "phone"');} if(typeof password==='undefined'){throw new AppwriteException('Missing required parameter: "password"');} -let path='/account/phone';let payload={};if(typeof number!=='undefined'){payload['number']=number;} +let path='/account/phone';let payload={};if(typeof phone!=='undefined'){payload['phone']=phone;} if(typeof password!=='undefined'){payload['password']=password;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('patch',uri,{'content-type':'application/json',},payload);});} getPrefs(){return __awaiter(this,void 0,void 0,function*(){let path='/account/prefs';let payload={};const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} @@ -115,10 +115,10 @@ if(typeof scopes!=='undefined'){payload['scopes']=scopes;} const uri=new URL(this.client.config.endpoint+path);payload['project']=this.client.config.project;for(const[key,value]of Object.entries(Service.flatten(payload))){uri.searchParams.append(key,value);} if(typeof window!=='undefined'&&(window===null||window===void 0?void 0:window.location)){window.location.href=uri.toString();} else{return uri;}} -createPhoneSession(userId,number){return __awaiter(this,void 0,void 0,function*(){if(typeof userId==='undefined'){throw new AppwriteException('Missing required parameter: "userId"');} -if(typeof number==='undefined'){throw new AppwriteException('Missing required parameter: "number"');} +createPhoneSession(userId,phone){return __awaiter(this,void 0,void 0,function*(){if(typeof userId==='undefined'){throw new AppwriteException('Missing required parameter: "userId"');} +if(typeof phone==='undefined'){throw new AppwriteException('Missing required parameter: "phone"');} let path='/account/sessions/phone';let payload={};if(typeof userId!=='undefined'){payload['userId']=userId;} -if(typeof number!=='undefined'){payload['number']=number;} +if(typeof phone!=='undefined'){payload['phone']=phone;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('post',uri,{'content-type':'application/json',},payload);});} updatePhoneSession(userId,secret){return __awaiter(this,void 0,void 0,function*(){if(typeof userId==='undefined'){throw new AppwriteException('Missing required parameter: "userId"');} if(typeof secret==='undefined'){throw new AppwriteException('Missing required parameter: "secret"');} @@ -442,6 +442,8 @@ if(typeof schedule!=='undefined'){payload['schedule']=schedule;} if(typeof timeout!=='undefined'){payload['timeout']=timeout;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('post',uri,{'content-type':'application/json',},payload);});} listRuntimes(){return __awaiter(this,void 0,void 0,function*(){let path='/functions/runtimes';let payload={};const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} +getUsage(range){return __awaiter(this,void 0,void 0,function*(){let path='/functions/usage';let payload={};if(typeof range!=='undefined'){payload['range']=range;} +const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} get(functionId){return __awaiter(this,void 0,void 0,function*(){if(typeof functionId==='undefined'){throw new AppwriteException('Missing required parameter: "functionId"');} let path='/functions/{functionId}'.replace('{functionId}',functionId);let payload={};const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} update(functionId,name,execute,vars,events,schedule,timeout){return __awaiter(this,void 0,void 0,function*(){if(typeof functionId==='undefined'){throw new AppwriteException('Missing required parameter: "functionId"');} @@ -504,7 +506,7 @@ const uri=new URL(this.client.config.endpoint+path);return yield this.client.cal getExecution(functionId,executionId){return __awaiter(this,void 0,void 0,function*(){if(typeof functionId==='undefined'){throw new AppwriteException('Missing required parameter: "functionId"');} if(typeof executionId==='undefined'){throw new AppwriteException('Missing required parameter: "executionId"');} let path='/functions/{functionId}/executions/{executionId}'.replace('{functionId}',functionId).replace('{executionId}',executionId);let payload={};const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} -getUsage(functionId,range){return __awaiter(this,void 0,void 0,function*(){if(typeof functionId==='undefined'){throw new AppwriteException('Missing required parameter: "functionId"');} +getFunctionUsage(functionId,range){return __awaiter(this,void 0,void 0,function*(){if(typeof functionId==='undefined'){throw new AppwriteException('Missing required parameter: "functionId"');} let path='/functions/{functionId}/usage'.replace('{functionId}',functionId);let payload={};if(typeof range!=='undefined'){payload['range']=range;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});}} class Health extends Service{constructor(client){super(client);} @@ -858,11 +860,10 @@ if(typeof cursor!=='undefined'){payload['cursor']=cursor;} if(typeof cursorDirection!=='undefined'){payload['cursorDirection']=cursorDirection;} if(typeof orderType!=='undefined'){payload['orderType']=orderType;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('get',uri,{'content-type':'application/json',},payload);});} -create(userId,email,password,name){return __awaiter(this,void 0,void 0,function*(){if(typeof userId==='undefined'){throw new AppwriteException('Missing required parameter: "userId"');} -if(typeof email==='undefined'){throw new AppwriteException('Missing required parameter: "email"');} -if(typeof password==='undefined'){throw new AppwriteException('Missing required parameter: "password"');} +create(userId,email,phone,password,name){return __awaiter(this,void 0,void 0,function*(){if(typeof userId==='undefined'){throw new AppwriteException('Missing required parameter: "userId"');} let path='/users';let payload={};if(typeof userId!=='undefined'){payload['userId']=userId;} if(typeof email!=='undefined'){payload['email']=email;} +if(typeof phone!=='undefined'){payload['phone']=phone;} if(typeof password!=='undefined'){payload['password']=password;} if(typeof name!=='undefined'){payload['name']=name;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('post',uri,{'content-type':'application/json',},payload);});} @@ -993,12 +994,91 @@ updatePhoneVerification(userId,phoneVerification){return __awaiter(this,void 0,v if(typeof phoneVerification==='undefined'){throw new AppwriteException('Missing required parameter: "phoneVerification"');} let path='/users/{userId}/verification/phone'.replace('{userId}',userId);let payload={};if(typeof phoneVerification!=='undefined'){payload['phoneVerification']=phoneVerification;} const uri=new URL(this.client.config.endpoint+path);return yield this.client.call('patch',uri,{'content-type':'application/json',},payload);});}} -exports.Account=Account;exports.AppwriteException=AppwriteException;exports.Avatars=Avatars;exports.Client=Client;exports.Databases=Databases;exports.Functions=Functions;exports.Health=Health;exports.Locale=Locale;exports.Projects=Projects;exports.Query=Query;exports.Storage=Storage;exports.Teams=Teams;exports.Users=Users;Object.defineProperty(exports,'__esModule',{value:true});})(this.Appwrite=this.Appwrite||{},null,window);(function(global,factory){typeof exports==='object'&&typeof module!=='undefined'?module.exports=factory():typeof define==='function'&&define.amd?define(factory):(global=typeof globalThis!=='undefined'?globalThis:global||self,global.Chart=factory());})(this,(function(){'use strict';function fontString(pixelSize,fontStyle,fontFamily){return fontStyle+' '+pixelSize+'px '+fontFamily;} +exports.Account=Account;exports.AppwriteException=AppwriteException;exports.Avatars=Avatars;exports.Client=Client;exports.Databases=Databases;exports.Functions=Functions;exports.Health=Health;exports.Locale=Locale;exports.Projects=Projects;exports.Query=Query;exports.Storage=Storage;exports.Teams=Teams;exports.Users=Users;Object.defineProperty(exports,'__esModule',{value:true});})(this.Appwrite=this.Appwrite||{},null,window);(function(global,factory){typeof exports==='object'&&typeof module!=='undefined'?module.exports=factory():typeof define==='function'&&define.amd?define(factory):(global=typeof globalThis!=='undefined'?globalThis:global||self,global.Chart=factory());})(this,(function(){'use strict';function noop(){} +const uid=(function(){let id=0;return function(){return id++;};}());function isNullOrUndef(value){return value===null||typeof value==='undefined';} +function isArray(value){if(Array.isArray&&Array.isArray(value)){return true;} +const type=Object.prototype.toString.call(value);if(type.slice(0,7)==='[object'&&type.slice(-6)==='Array]'){return true;} +return false;} +function isObject(value){return value!==null&&Object.prototype.toString.call(value)==='[object Object]';} +const isNumberFinite=(value)=>(typeof value==='number'||value instanceof Number)&&isFinite(+value);function finiteOrDefault(value,defaultValue){return isNumberFinite(value)?value:defaultValue;} +function valueOrDefault(value,defaultValue){return typeof value==='undefined'?defaultValue:value;} +const toPercentage=(value,dimension)=>typeof value==='string'&&value.endsWith('%')?parseFloat(value)/100:value/dimension;const toDimension=(value,dimension)=>typeof value==='string'&&value.endsWith('%')?parseFloat(value)/100*dimension:+value;function callback(fn,args,thisArg){if(fn&&typeof fn.call==='function'){return fn.apply(thisArg,args);}} +function each(loopable,fn,thisArg,reverse){let i,len,keys;if(isArray(loopable)){len=loopable.length;if(reverse){for(i=len-1;i>=0;i--){fn.call(thisArg,loopable[i],i);}}else{for(i=0;iv,x:o=>o.x,y:o=>o.y};function resolveObjectKey(obj,key){const resolver=keyResolvers[key]||(keyResolvers[key]=_getKeyResolver(key));return resolver(obj);} +function _getKeyResolver(key){const keys=_splitKey(key);return obj=>{for(const k of keys){if(k===''){break;} +obj=obj&&obj[k];} +return obj;};} +function _splitKey(key){const parts=key.split('.');const keys=[];let tmp='';for(const part of parts){tmp+=part;if(tmp.endsWith('\\')){tmp=tmp.slice(0,-1)+'.';}else{keys.push(tmp);tmp='';}} +return keys;} +function _capitalize(str){return str.charAt(0).toUpperCase()+str.slice(1);} +const defined=(value)=>typeof value!=='undefined';const isFunction=(value)=>typeof value==='function';const setsEqual=(a,b)=>{if(a.size!==b.size){return false;} +for(const item of a){if(!b.has(item)){return false;}} +return true;};function _isClickEvent(e){return e.type==='mouseup'||e.type==='click'||e.type==='contextmenu';} +const PI=Math.PI;const TAU=2*PI;const PITAU=TAU+PI;const INFINITY=Number.POSITIVE_INFINITY;const RAD_PER_DEG=PI/180;const HALF_PI=PI/2;const QUARTER_PI=PI/4;const TWO_THIRDS_PI=PI*2/3;const log10=Math.log10;const sign=Math.sign;function niceNum(range){const roundedRange=Math.round(range);range=almostEquals(range,roundedRange,range/1000)?roundedRange:range;const niceRange=Math.pow(10,Math.floor(log10(range)));const fraction=range/niceRange;const niceFraction=fraction<=1?1:fraction<=2?2:fraction<=5?5:10;return niceFraction*niceRange;} +function _factorize(value){const result=[];const sqrt=Math.sqrt(value);let i;for(i=1;ia-b).pop();return result;} +function isNumber(n){return!isNaN(parseFloat(n))&&isFinite(n);} +function almostEquals(x,y,epsilon){return Math.abs(x-y)=x);} +function _setMinAndMaxByKey(array,target,property){let i,ilen,value;for(i=0,ilen=array.length;iangleToEnd&&startToAngle=Math.min(start,end)-epsilon&&value<=Math.max(start,end)+epsilon;} +function _lookup(table,value,cmp){cmp=cmp||((index)=>table[index]1){mid=(lo+hi)>>1;if(cmp(mid)){lo=mid;}else{hi=mid;}} +return{lo,hi};} +const _lookupByKey=(table,key,value,last)=>_lookup(table,value,last?index=>table[index][key]<=value:index=>table[index][key]_lookup(table,value,index=>table[index][key]>=value);function _filterBetween(values,min,max){let start=0;let end=values.length;while(startstart&&values[end-1]>max){end--;} +return start>0||end{const method='_onData'+_capitalize(key);const base=array[key];Object.defineProperty(array,key,{configurable:true,enumerable:false,value(...args){const res=base.apply(this,args);array._chartjs.listeners.forEach((object)=>{if(typeof object[method]==='function'){object[method](...args);}});return res;}});});} +function unlistenArrayEvents(array,listener){const stub=array._chartjs;if(!stub){return;} +const listeners=stub.listeners;const index=listeners.indexOf(listener);if(index!==-1){listeners.splice(index,1);} +if(listeners.length>0){return;} +arrayEvents.forEach((key)=>{delete array[key];});delete array._chartjs;} +function _arrayUnique(items){const set=new Set();let i,ilen;for(i=0,ilen=items.length;iArray.prototype.slice.call(args));let ticking=false;let args=[];return function(...rest){args=updateArgs(rest);if(!ticking){ticking=true;requestAnimFrame.call(window,()=>{ticking=false;fn.apply(thisArg,args);});}};} function debounce(fn,delay){let timeout;return function(...args){if(delay){clearTimeout(timeout);timeout=setTimeout(fn,delay,args);}else{fn.apply(this,args);} return delay;};} -const _toLeftRightCenter=(align)=>align==='start'?'left':align==='end'?'right':'center';const _alignStartEnd=(align,start,end)=>align==='start'?start:align==='end'?end:(start+end)/2;const _textX=(align,left,right,rtl)=>{const check=rtl?'left':'right';return align===check?right:align==='center'?(left+right)/2:left;};class Animator{constructor(){this._request=null;this._charts=new Map();this._running=false;this._lastDate=undefined;} +const _toLeftRightCenter=(align)=>align==='start'?'left':align==='end'?'right':'center';const _alignStartEnd=(align,start,end)=>align==='start'?start:align==='end'?end:(start+end)/2;const _textX=(align,left,right,rtl)=>{const check=rtl?'left':'right';return align===check?right:align==='center'?(left+right)/2:left;};function _getStartAndCountOfVisiblePoints(meta,points,animationsDisabled){const pointCount=points.length;let start=0;let count=pointCount;if(meta._sorted){const{iScale,_parsed}=meta;const axis=iScale.axis;const{min,max,minDefined,maxDefined}=iScale.getUserBounds();if(minDefined){start=_limitValue(Math.min(_lookupByKey(_parsed,iScale.axis,min).lo,animationsDisabled?pointCount:_lookupByKey(points,axis,iScale.getPixelForValue(min)).lo),0,pointCount-1);} +if(maxDefined){count=_limitValue(Math.max(_lookupByKey(_parsed,iScale.axis,max,true).hi+1,animationsDisabled?0:_lookupByKey(points,axis,iScale.getPixelForValue(max),true).hi+1),start,pointCount)-start;}else{count=pointCount-start;}} +return{start,count};} +function _scaleRangesChanged(meta){const{xScale,yScale,_scaleRanges}=meta;const newRanges={xmin:xScale.min,xmax:xScale.max,ymin:yScale.min,ymax:yScale.max};if(!_scaleRanges){meta._scaleRanges=newRanges;return true;} +const changed=_scaleRanges.xmin!==xScale.min||_scaleRanges.xmax!==xScale.max||_scaleRanges.ymin!==yScale.min||_scaleRanges.ymax!==yScale.max;Object.assign(_scaleRanges,newRanges);return changed;} +class Animator{constructor(){this._request=null;this._charts=new Map();this._running=false;this._lastDate=undefined;} _notify(chart,anims,date,type){const callbacks=anims.listeners[type];const numSteps=anims.duration;callbacks.forEach(fn=>fn({chart,initial:anims.initial,numSteps,currentStep:Math.min(date-anims.start,numSteps)}));} _refresh(){if(this._request){return;} this._running=true;this._request=requestAnimFrame.call(window,()=>{this._update();this._request=null;if(this._running){this._refresh();}});} @@ -1064,8 +1144,8 @@ r=+m[1];g=+m[3];b=+m[5];r=255&(m[2]?p2b(r):lim(r,0,255));g=255&(m[4]?p2b(g):lim( function rgbString(v){return v&&(v.a<255?`rgba(${v.r}, ${v.g}, ${v.b}, ${b2n(v.a)})`:`rgb(${v.r}, ${v.g}, ${v.b})`);} const to=v=>v<=0.0031308?v*12.92:Math.pow(v,1.0/2.4)*1.055-0.055;const from=v=>v<=0.04045?v/12.92:Math.pow((v+0.055)/1.055,2.4);function interpolate$1(rgb1,rgb2,t){const r=from(b2n(rgb1.r));const g=from(b2n(rgb1.g));const b=from(b2n(rgb1.b));return{r:n2b(to(r+t*(from(b2n(rgb2.r))-r))),g:n2b(to(g+t*(from(b2n(rgb2.g))-g))),b:n2b(to(b+t*(from(b2n(rgb2.b))-b))),a:rgb1.a+t*(rgb2.a-rgb1.a)};} function modHSL(v,i,ratio){if(v){let tmp=rgb2hsl(v);tmp[i]=Math.max(0,Math.min(tmp[i]+tmp[i]*ratio,i===0?360:1));tmp=hsl2rgb(tmp);v.r=tmp[0];v.g=tmp[1];v.b=tmp[2];}} -function clone$1(v,proto){return v?Object.assign(proto||{},v):v;} -function fromObject(input){var v={r:0,g:0,b:0,a:255};if(Array.isArray(input)){if(input.length>=3){v={r:input[0],g:input[1],b:input[2],a:255};if(input.length>3){v.a=n2b(input[3]);}}}else{v=clone$1(input,{r:0,g:0,b:0,a:1});v.a=n2b(v.a);} +function clone(v,proto){return v?Object.assign(proto||{},v):v;} +function fromObject(input){var v={r:0,g:0,b:0,a:255};if(Array.isArray(input)){if(input.length>=3){v={r:input[0],g:input[1],b:input[2],a:255};if(input.length>3){v.a=n2b(input[3]);}}}else{v=clone(input,{r:0,g:0,b:0,a:1});v.a=n2b(v.a);} return v;} function functionParse(str){if(str.charAt(0)==='r'){return rgbParse(str);} return hueParse(str);} @@ -1073,7 +1153,7 @@ class Color{constructor(input){if(input instanceof Color){return input;} const type=typeof input;let v;if(type==='object'){v=fromObject(input);}else if(type==='string'){v=hexParse(input)||nameParse(input)||functionParse(input);} this._rgb=v;this._valid=!!v;} get valid(){return this._valid;} -get rgb(){var v=clone$1(this._rgb);if(v){v.a=b2n(v.a);} +get rgb(){var v=clone(this._rgb);if(v){v.a=b2n(v.a);} return v;} set rgb(obj){this._rgb=fromObject(obj);} rgbString(){return this._valid?rgbString(this._rgb):undefined;} @@ -1099,42 +1179,6 @@ function isPatternOrGradient(value){if(value&&typeof value==='object'){const typ return false;} function color(value){return isPatternOrGradient(value)?value:index_esm(value);} function getHoverColor(value){return isPatternOrGradient(value)?value:index_esm(value).saturate(0.5).darken(0.1).hexString();} -function noop(){} -const uid=(function(){let id=0;return function(){return id++;};}());function isNullOrUndef(value){return value===null||typeof value==='undefined';} -function isArray(value){if(Array.isArray&&Array.isArray(value)){return true;} -const type=Object.prototype.toString.call(value);if(type.slice(0,7)==='[object'&&type.slice(-6)==='Array]'){return true;} -return false;} -function isObject(value){return value!==null&&Object.prototype.toString.call(value)==='[object Object]';} -const isNumberFinite=(value)=>(typeof value==='number'||value instanceof Number)&&isFinite(+value);function finiteOrDefault(value,defaultValue){return isNumberFinite(value)?value:defaultValue;} -function valueOrDefault(value,defaultValue){return typeof value==='undefined'?defaultValue:value;} -const toPercentage=(value,dimension)=>typeof value==='string'&&value.endsWith('%')?parseFloat(value)/100:value/dimension;const toDimension=(value,dimension)=>typeof value==='string'&&value.endsWith('%')?parseFloat(value)/100*dimension:+value;function callback(fn,args,thisArg){if(fn&&typeof fn.call==='function'){return fn.apply(thisArg,args);}} -function each(loopable,fn,thisArg,reverse){let i,len,keys;if(isArray(loopable)){len=loopable.length;if(reverse){for(i=len-1;i>=0;i--){fn.call(thisArg,loopable[i],i);}}else{for(i=0;ipos){obj=obj[key.slice(pos,idx)];pos=idx+1;idx=indexOfDotOrLength(key,pos);} -return obj;} -function _capitalize(str){return str.charAt(0).toUpperCase()+str.slice(1);} -const defined=(value)=>typeof value!=='undefined';const isFunction=(value)=>typeof value==='function';const setsEqual=(a,b)=>{if(a.size!==b.size){return false;} -for(const item of a){if(!b.has(item)){return false;}} -return true;};function _isClickEvent(e){return e.type==='mouseup'||e.type==='click'||e.type==='contextmenu';} const overrides=Object.create(null);const descriptors=Object.create(null);function getScope$1(node,key){if(!key){return node;} const keys=key.split('.');for(let i=0,n=keys.length;i!name.startsWith('on'),_indexable:(name)=>name!=='events',hover:{_fallback:'interaction'},interaction:{_scriptable:false,_indexable:false,}});function _lookup(table,value,cmp){cmp=cmp||((index)=>table[index]1){mid=(lo+hi)>>1;if(cmp(mid)){lo=mid;}else{hi=mid;}} -return{lo,hi};} -const _lookupByKey=(table,key,value)=>_lookup(table,value,index=>table[index][key]_lookup(table,value,index=>table[index][key]>=value);function _filterBetween(values,min,max){let start=0;let end=values.length;while(startstart&&values[end-1]>max){end--;} -return start>0||end{const method='_onData'+_capitalize(key);const base=array[key];Object.defineProperty(array,key,{configurable:true,enumerable:false,value(...args){const res=base.apply(this,args);array._chartjs.listeners.forEach((object)=>{if(typeof object[method]==='function'){object[method](...args);}});return res;}});});} -function unlistenArrayEvents(array,listener){const stub=array._chartjs;if(!stub){return;} -const listeners=stub.listeners;const index=listeners.indexOf(listener);if(index!==-1){listeners.splice(index,1);} -if(listeners.length>0){return;} -arrayEvents.forEach((key)=>{delete array[key];});delete array._chartjs;} -function _arrayUnique(items){const set=new Set();let i,ilen;for(i=0,ilen=items.length;ia-b).pop();return result;} -function isNumber(n){return!isNaN(parseFloat(n))&&isFinite(n);} -function almostEquals(x,y,epsilon){return Math.abs(x-y)=x);} -function _setMinAndMaxByKey(array,target,property){let i,ilen,value;for(i=0,ilen=array.length;iangleToEnd&&startToAngle=Math.min(start,end)-epsilon&&value<=Math.max(start,end)+epsilon;} -function _isDomSupported(){return typeof window!=='undefined'&&typeof document!=='undefined';} +var defaults=new Defaults({_scriptable:(name)=>!name.startsWith('on'),_indexable:(name)=>name!=='events',hover:{_fallback:'interaction'},interaction:{_scriptable:false,_indexable:false,}});function _isDomSupported(){return typeof window!=='undefined'&&typeof document!=='undefined';} function _getParentNode(domNode){let parent=domNode.parentNode;if(parent&&parent.toString()==='[object ShadowRoot]'){parent=parent.host;} return parent;} function parseMaxStyle(styleValue,node,parentProperty){let valueInPixels;if(typeof styleValue==='string'){valueInPixels=parseInt(styleValue,10);if(styleValue.indexOf('%')!==-1){valueInPixels=valueInPixels/100*node.parentNode[parentProperty];}}else{valueInPixels=styleValue;} @@ -1376,7 +1384,7 @@ if(start0;} _computeGridLineItems(chartArea){const axis=this.axis;const chart=this.chart;const options=this.options;const{grid,position}=options;const offset=grid.offset;const isHorizontal=this.isHorizontal();const ticks=this.ticks;const ticksLength=ticks.length+(offset?1:0);const tl=getTickMarkLength(grid);const items=[];const borderOpts=grid.setContext(this.getContext());const axisWidth=borderOpts.drawBorder?borderOpts.borderWidth:0;const axisHalfWidth=axisWidth/2;const alignBorderValue=function(pixel){return _alignPixel(chart,pixel,axisWidth);};let borderValue,i,lineValue,alignedLineValue;let tx1,ty1,tx2,ty2,x1,y1,x2,y2;if(position==='top'){borderValue=alignBorderValue(this.bottom);ty1=this.bottom-tl;ty2=borderValue-axisHalfWidth;y1=alignBorderValue(chartArea.top)+axisHalfWidth;y2=chartArea.bottom;}else if(position==='bottom'){borderValue=alignBorderValue(this.top);y1=chartArea.top;y2=alignBorderValue(chartArea.bottom)-axisHalfWidth;ty1=borderValue+axisHalfWidth;ty2=this.top+tl;}else if(position==='left'){borderValue=alignBorderValue(this.right);tx1=this.right-tl;tx2=borderValue-axisHalfWidth;x1=alignBorderValue(chartArea.left)+axisHalfWidth;x2=chartArea.right;}else if(position==='right'){borderValue=alignBorderValue(this.left);x1=chartArea.left;x2=alignBorderValue(chartArea.right)-axisHalfWidth;tx1=borderValue+axisHalfWidth;tx2=this.left+tl;}else if(axis==='x'){if(position==='center'){borderValue=alignBorderValue((chartArea.top+chartArea.bottom)/2+0.5);}else if(isObject(position)){const positionAxisID=Object.keys(position)[0];const value=position[positionAxisID];borderValue=alignBorderValue(this.chart.scales[positionAxisID].getPixelForValue(value));} y1=chartArea.top;y2=chartArea.bottom;ty1=borderValue+axisHalfWidth;ty2=ty1+tl;}else if(axis==='y'){if(position==='center'){borderValue=alignBorderValue((chartArea.left+chartArea.right)/2);}else if(isObject(position)){const positionAxisID=Object.keys(position)[0];const value=position[positionAxisID];borderValue=alignBorderValue(this.chart.scales[positionAxisID].getPixelForValue(value));} tx1=borderValue-axisHalfWidth;tx2=tx1-tl;x1=chartArea.left;x2=chartArea.right;} -const limit=valueOrDefault(options.ticks.maxTicksLimit,ticksLength);const step=Math.max(1,Math.ceil(ticksLength/limit));for(i=0;iisObject(value)&&Object.getOwnPropertyNames(value).reduce((acc,key)=>acc||isFunction(value[key]),false);function needContext(proxy,names){const{isScriptable,isIndexable}=_descriptors(proxy);for(const prop of names){const scriptable=isScriptable(prop);const indexable=isIndexable(prop);const value=(indexable||scriptable)&&proxy[prop];if((scriptable&&(isFunction(value)||hasFunction(value)))||(indexable&&isArray(value))){return true;}} return false;} -var version="3.8.2";const KNOWN_POSITIONS=['top','bottom','left','right','chartArea'];function positionIsHorizontal(position,axis){return position==='top'||position==='bottom'||(KNOWN_POSITIONS.indexOf(position)===-1&&axis==='x');} +var version="3.9.1";const KNOWN_POSITIONS=['top','bottom','left','right','chartArea'];function positionIsHorizontal(position,axis){return position==='top'||position==='bottom'||(KNOWN_POSITIONS.indexOf(position)===-1&&axis==='x');} function compare2Level(l1,l2){return function(a,b){return a[l1]===b[l1]?a[l2]-b[l2]:a[l1]-b[l1];};} function onAnimationsComplete(context){const chart=context.chart;const animationOptions=chart.options.animation;chart.notifyPlugins('afterRender');callback(animationOptions&&animationOptions.onComplete,[context],chart);} function onAnimationProgress(context){const chart=context.chart;const animationOptions=chart.options.animation;callback(animationOptions&&animationOptions.onProgress,[context],chart);} @@ -2028,6 +2036,7 @@ if(!inChartArea){return lastActive;} const hoverOptions=this.options.hover;return this.getElementsAtEventForMode(e,hoverOptions.mode,hoverOptions,useFinalPosition);}} const invalidatePlugins=()=>each(Chart.instances,(chart)=>chart._plugins.invalidate());const enumerable=true;Object.defineProperties(Chart,{defaults:{enumerable,value:defaults},instances:{enumerable,value:instances},overrides:{enumerable,value:overrides},registry:{enumerable,value:registry},version:{enumerable,value:version},getChart:{enumerable,value:getChart},register:{enumerable,value:(...items)=>{registry.add(...items);invalidatePlugins();}},unregister:{enumerable,value:(...items)=>{registry.remove(...items);invalidatePlugins();}}});function abstract(){throw new Error('This method is not implemented: Check that a complete date adapter is provided.');} class DateAdapter{constructor(options){this.options=options||{};} +init(chartOptions){} formats(){return abstract();} parse(value,format){return abstract();} format(timestamp,format){return abstract();} @@ -2061,6 +2070,7 @@ function borderProps(properties){let reverse,start,end,top,bottom;if(properties. if(reverse){top='end';bottom='start';}else{top='start';bottom='end';} return{start,end,reverse,top,bottom};} function setBorderSkipped(properties,options,stack,index){let edge=options.borderSkipped;const res={};if(!edge){properties.borderSkipped=res;return;} +if(edge===true){properties.borderSkipped={top:true,right:true,bottom:true,left:true};return;} const{start,end,reverse,top,bottom}=borderProps(properties);if(edge==='middle'&&stack){properties.enableBorderRadius=true;if((stack._top||0)===index){edge=top;}else if((stack._bottom||0)===index){edge=bottom;}else{res[parseEdge(bottom,start,end,reverse)]=true;edge=top;}} res[parseEdge(edge,start,end,reverse)]=true;properties.borderSkipped=res;} function parseEdge(edge,a,b,reverse){if(reverse){edge=swap(edge,a,b);edge=startEnd(edge,b,a);}else{edge=startEnd(edge,a,b);} @@ -2149,7 +2159,7 @@ _getVisibleDatasetWeightTotal(){return this._getRingWeightOffset(this.chart.data DoughnutController.id='doughnut';DoughnutController.defaults={datasetElementType:false,dataElementType:'arc',animation:{animateRotate:true,animateScale:false},animations:{numbers:{type:'number',properties:['circumference','endAngle','innerRadius','outerRadius','startAngle','x','y','offset','borderWidth','spacing']},},cutout:'50%',rotation:0,circumference:360,radius:'100%',spacing:0,indexAxis:'r',};DoughnutController.descriptors={_scriptable:(name)=>name!=='spacing',_indexable:(name)=>name!=='spacing',};DoughnutController.overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(chart){const data=chart.data;if(data.labels.length&&data.datasets.length){const{labels:{pointStyle}}=chart.legend.options;return data.labels.map((label,i)=>{const meta=chart.getDatasetMeta(0);const style=meta.controller.getStyle(i);return{text:label,fillStyle:style.backgroundColor,strokeStyle:style.borderColor,lineWidth:style.borderWidth,pointStyle:pointStyle,hidden:!chart.getDataVisibility(i),index:i};});} return[];}},onClick(e,legendItem,legend){legend.chart.toggleDataVisibility(legendItem.index);legend.chart.update();}},tooltip:{callbacks:{title(){return'';},label(tooltipItem){let dataLabel=tooltipItem.label;const value=': '+tooltipItem.formattedValue;if(isArray(dataLabel)){dataLabel=dataLabel.slice();dataLabel[0]+=value;}else{dataLabel+=value;} return dataLabel;}}}}};class LineController extends DatasetController{initialize(){this.enableOptionSharing=true;this.supportsDecimation=true;super.initialize();} -update(mode){const meta=this._cachedMeta;const{dataset:line,data:points=[],_dataset}=meta;const animationsDisabled=this.chart._animationsDisabled;let{start,count}=getStartAndCountOfVisiblePoints(meta,points,animationsDisabled);this._drawStart=start;this._drawCount=count;if(scaleRangesChanged(meta)){start=0;count=points.length;} +update(mode){const meta=this._cachedMeta;const{dataset:line,data:points=[],_dataset}=meta;const animationsDisabled=this.chart._animationsDisabled;let{start,count}=_getStartAndCountOfVisiblePoints(meta,points,animationsDisabled);this._drawStart=start;this._drawCount=count;if(_scaleRangesChanged(meta)){start=0;count=points.length;} line._chart=this.chart;line._datasetIndex=this.index;line._decimated=!!_dataset._decimated;line.points=points;const options=this.resolveDatasetElementOptions(mode);if(!this.options.showLine){options.borderWidth=0;} options.segment=this.options.segment;this.updateElement(line,undefined,{animated:!animationsDisabled,options},mode);this.updateElements(points,start,count,mode);} updateElements(points,start,count,mode){const reset=mode==='reset';const{iScale,vScale,_stacked,_dataset}=this._cachedMeta;const{sharedOptions,includeOptions}=this._getSharedOptions(start,mode);const iAxis=iScale.axis;const vAxis=vScale.axis;const{spanGaps,segment}=this.options;const maxGapLength=isNumber(spanGaps)?spanGaps:Number.POSITIVE_INFINITY;const directUpdate=this.chart._animationsDisabled||reset||mode==='none';let prevParsed=start>0&&this.getParsed(start-1);for(let i=start;i0&&(Math.abs(parsed[iAxis]-prevParsed[iAxis]))>maxGapLength;if(segment){properties.parsed=parsed;properties.raw=_dataset.data[i];} @@ -2159,12 +2169,7 @@ prevParsed=parsed;}} getMaxOverflow(){const meta=this._cachedMeta;const dataset=meta.dataset;const border=dataset.options&&dataset.options.borderWidth||0;const data=meta.data||[];if(!data.length){return border;} const firstPoint=data[0].size(this.resolveDataElementOptions(0));const lastPoint=data[data.length-1].size(this.resolveDataElementOptions(data.length-1));return Math.max(border,firstPoint,lastPoint)/2;} draw(){const meta=this._cachedMeta;meta.dataset.updateControlPoints(this.chart.chartArea,meta.iScale.axis);super.draw();}} -LineController.id='line';LineController.defaults={datasetElementType:'line',dataElementType:'point',showLine:true,spanGaps:false,};LineController.overrides={scales:{_index_:{type:'category',},_value_:{type:'linear',},}};function getStartAndCountOfVisiblePoints(meta,points,animationsDisabled){const pointCount=points.length;let start=0;let count=pointCount;if(meta._sorted){const{iScale,_parsed}=meta;const axis=iScale.axis;const{min,max,minDefined,maxDefined}=iScale.getUserBounds();if(minDefined){start=_limitValue(Math.min(_lookupByKey(_parsed,iScale.axis,min).lo,animationsDisabled?pointCount:_lookupByKey(points,axis,iScale.getPixelForValue(min)).lo),0,pointCount-1);} -if(maxDefined){count=_limitValue(Math.max(_lookupByKey(_parsed,iScale.axis,max).hi+1,animationsDisabled?0:_lookupByKey(points,axis,iScale.getPixelForValue(max)).hi+1),start,pointCount)-start;}else{count=pointCount-start;}} -return{start,count};} -function scaleRangesChanged(meta){const{xScale,yScale,_scaleRanges}=meta;const newRanges={xmin:xScale.min,xmax:xScale.max,ymin:yScale.min,ymax:yScale.max};if(!_scaleRanges){meta._scaleRanges=newRanges;return true;} -const changed=_scaleRanges.xmin!==xScale.min||_scaleRanges.xmax!==xScale.max||_scaleRanges.ymin!==yScale.min||_scaleRanges.ymax!==yScale.max;Object.assign(_scaleRanges,newRanges);return changed;} -class PolarAreaController extends DatasetController{constructor(chart,datasetIndex){super(chart,datasetIndex);this.innerRadius=undefined;this.outerRadius=undefined;} +LineController.id='line';LineController.defaults={datasetElementType:'line',dataElementType:'point',showLine:true,spanGaps:false,};LineController.overrides={scales:{_index_:{type:'category',},_value_:{type:'linear',},}};class PolarAreaController extends DatasetController{constructor(chart,datasetIndex){super(chart,datasetIndex);this.innerRadius=undefined;this.outerRadius=undefined;} getLabelAndValue(index){const meta=this._cachedMeta;const chart=this.chart;const labels=chart.data.labels||[];const value=formatNumber(meta._parsed[index].r,chart.options.locale);return{label:labels[index]||'',value,};} parseObjectData(meta,data,start,count){return _parseObjectDataRadialScale.bind(this)(meta,data,start,count);} update(mode){const arcs=this._cachedMeta.data;this._updateRadius();this.updateElements(arcs,0,arcs.length,mode);} @@ -2185,37 +2190,50 @@ update(mode){const meta=this._cachedMeta;const line=meta.dataset;const points=me const properties={_loop:true,_fullLoop:labels.length===points.length,options};this.updateElement(line,undefined,properties,mode);} this.updateElements(points,0,points.length,mode);} updateElements(points,start,count,mode){const scale=this._cachedMeta.rScale;const reset=mode==='reset';for(let i=start;ipixelMargin){angleMargin=pixelMargin/innerRadius;ctx.arc(x,y,innerRadius,endAngle+angleMargin,startAngle-angleMargin,true);}else{ctx.arc(x,y,pixelMargin,endAngle+HALF_PI,startAngle-HALF_PI);} +RadarController.id='radar';RadarController.defaults={datasetElementType:'line',dataElementType:'point',indexAxis:'r',showLine:true,elements:{line:{fill:'start'}},};RadarController.overrides={aspectRatio:1,scales:{r:{type:'radialLinear',}}};class ScatterController extends DatasetController{update(mode){const meta=this._cachedMeta;const{data:points=[]}=meta;const animationsDisabled=this.chart._animationsDisabled;let{start,count}=_getStartAndCountOfVisiblePoints(meta,points,animationsDisabled);this._drawStart=start;this._drawCount=count;if(_scaleRangesChanged(meta)){start=0;count=points.length;} +if(this.options.showLine){const{dataset:line,_dataset}=meta;line._chart=this.chart;line._datasetIndex=this.index;line._decimated=!!_dataset._decimated;line.points=points;const options=this.resolveDatasetElementOptions(mode);options.segment=this.options.segment;this.updateElement(line,undefined,{animated:!animationsDisabled,options},mode);} +this.updateElements(points,start,count,mode);} +addElements(){const{showLine}=this.options;if(!this.datasetElementType&&showLine){this.datasetElementType=registry.getElement('line');} +super.addElements();} +updateElements(points,start,count,mode){const reset=mode==='reset';const{iScale,vScale,_stacked,_dataset}=this._cachedMeta;const firstOpts=this.resolveDataElementOptions(start,mode);const sharedOptions=this.getSharedOptions(firstOpts);const includeOptions=this.includeOptions(mode,sharedOptions);const iAxis=iScale.axis;const vAxis=vScale.axis;const{spanGaps,segment}=this.options;const maxGapLength=isNumber(spanGaps)?spanGaps:Number.POSITIVE_INFINITY;const directUpdate=this.chart._animationsDisabled||reset||mode==='none';let prevParsed=start>0&&this.getParsed(start-1);for(let i=start;i0&&(Math.abs(parsed[iAxis]-prevParsed[iAxis]))>maxGapLength;if(segment){properties.parsed=parsed;properties.raw=_dataset.data[i];} +if(includeOptions){properties.options=sharedOptions||this.resolveDataElementOptions(i,point.active?'active':mode);} +if(!directUpdate){this.updateElement(point,i,properties,mode);} +prevParsed=parsed;} +this.updateSharedOptions(sharedOptions,mode,firstOpts);} +getMaxOverflow(){const meta=this._cachedMeta;const data=meta.data||[];if(!this.options.showLine){let max=0;for(let i=data.length-1;i>=0;--i){max=Math.max(max,data[i].size(this.resolveDataElementOptions(i))/2);} +return max>0&&max;} +const dataset=meta.dataset;const border=dataset.options&&dataset.options.borderWidth||0;if(!data.length){return border;} +const firstPoint=data[0].size(this.resolveDataElementOptions(0));const lastPoint=data[data.length-1].size(this.resolveDataElementOptions(data.length-1));return Math.max(border,firstPoint,lastPoint)/2;}} +ScatterController.id='scatter';ScatterController.defaults={datasetElementType:false,dataElementType:'point',showLine:false,fill:false};ScatterController.overrides={interaction:{mode:'point'},plugins:{tooltip:{callbacks:{title(){return'';},label(item){return'('+item.label+', '+item.formattedValue+')';}}}},scales:{x:{type:'linear'},y:{type:'linear'}}};var controllers=Object.freeze({__proto__:null,BarController:BarController,BubbleController:BubbleController,DoughnutController:DoughnutController,LineController:LineController,PolarAreaController:PolarAreaController,PieController:PieController,RadarController:RadarController,ScatterController:ScatterController});function clipArc(ctx,element,endAngle){const{startAngle,pixelMargin,x,y,outerRadius,innerRadius}=element;let angleMargin=pixelMargin/outerRadius;ctx.beginPath();ctx.arc(x,y,outerRadius,startAngle-angleMargin,endAngle+angleMargin);if(innerRadius>pixelMargin){angleMargin=pixelMargin/innerRadius;ctx.arc(x,y,innerRadius,endAngle+angleMargin,startAngle-angleMargin,true);}else{ctx.arc(x,y,pixelMargin,endAngle+HALF_PI,startAngle-HALF_PI);} ctx.closePath();ctx.clip();} function toRadiusCorners(value){return _readValueToProps(value,['outerStart','outerEnd','innerStart','innerEnd']);} function parseBorderRadius$1(arc,innerRadius,outerRadius,angleDelta){const o=toRadiusCorners(arc.options.borderRadius);const halfThickness=(outerRadius-innerRadius)/2;const innerLimit=Math.min(halfThickness,angleDelta*innerRadius/2);const computeOuterLimit=(val)=>{const outerArcLimit=(outerRadius-Math.min(halfThickness,val))*angleDelta/2;return _limitValue(val,0,Math.min(halfThickness,outerArcLimit));};return{outerStart:computeOuterLimit(o.outerStart),outerEnd:computeOuterLimit(o.outerEnd),innerStart:_limitValue(o.innerStart,0,innerLimit),innerEnd:_limitValue(o.innerEnd,0,innerLimit),};} function rThetaToXY(r,theta,x,y){return{x:x+r*Math.cos(theta),y:y+r*Math.sin(theta),};} -function pathArc(ctx,element,offset,spacing,end){const{x,y,startAngle:start,pixelMargin,innerRadius:innerR}=element;const outerRadius=Math.max(element.outerRadius+spacing+offset-pixelMargin,0);const innerRadius=innerR>0?innerR+spacing+offset+pixelMargin:0;let spacingOffset=0;const alpha=end-start;if(spacing){const noSpacingInnerRadius=innerR>0?innerR-spacing:0;const noSpacingOuterRadius=outerRadius>0?outerRadius-spacing:0;const avNogSpacingRadius=(noSpacingInnerRadius+noSpacingOuterRadius)/2;const adjustedAngle=avNogSpacingRadius!==0?(alpha*avNogSpacingRadius)/(avNogSpacingRadius+spacing):alpha;spacingOffset=(alpha-adjustedAngle)/2;} -const beta=Math.max(0.001,alpha*outerRadius-offset/PI)/outerRadius;const angleOffset=(alpha-beta)/2;const startAngle=start+angleOffset+spacingOffset;const endAngle=end-angleOffset-spacingOffset;const{outerStart,outerEnd,innerStart,innerEnd}=parseBorderRadius$1(element,innerRadius,outerRadius,endAngle-startAngle);const outerStartAdjustedRadius=outerRadius-outerStart;const outerEndAdjustedRadius=outerRadius-outerEnd;const outerStartAdjustedAngle=startAngle+outerStart/outerStartAdjustedRadius;const outerEndAdjustedAngle=endAngle-outerEnd/outerEndAdjustedRadius;const innerStartAdjustedRadius=innerRadius+innerStart;const innerEndAdjustedRadius=innerRadius+innerEnd;const innerStartAdjustedAngle=startAngle+innerStart/innerStartAdjustedRadius;const innerEndAdjustedAngle=endAngle-innerEnd/innerEndAdjustedRadius;ctx.beginPath();ctx.arc(x,y,outerRadius,outerStartAdjustedAngle,outerEndAdjustedAngle);if(outerEnd>0){const pCenter=rThetaToXY(outerEndAdjustedRadius,outerEndAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,outerEnd,outerEndAdjustedAngle,endAngle+HALF_PI);} +function pathArc(ctx,element,offset,spacing,end,circular){const{x,y,startAngle:start,pixelMargin,innerRadius:innerR}=element;const outerRadius=Math.max(element.outerRadius+spacing+offset-pixelMargin,0);const innerRadius=innerR>0?innerR+spacing+offset+pixelMargin:0;let spacingOffset=0;const alpha=end-start;if(spacing){const noSpacingInnerRadius=innerR>0?innerR-spacing:0;const noSpacingOuterRadius=outerRadius>0?outerRadius-spacing:0;const avNogSpacingRadius=(noSpacingInnerRadius+noSpacingOuterRadius)/2;const adjustedAngle=avNogSpacingRadius!==0?(alpha*avNogSpacingRadius)/(avNogSpacingRadius+spacing):alpha;spacingOffset=(alpha-adjustedAngle)/2;} +const beta=Math.max(0.001,alpha*outerRadius-offset/PI)/outerRadius;const angleOffset=(alpha-beta)/2;const startAngle=start+angleOffset+spacingOffset;const endAngle=end-angleOffset-spacingOffset;const{outerStart,outerEnd,innerStart,innerEnd}=parseBorderRadius$1(element,innerRadius,outerRadius,endAngle-startAngle);const outerStartAdjustedRadius=outerRadius-outerStart;const outerEndAdjustedRadius=outerRadius-outerEnd;const outerStartAdjustedAngle=startAngle+outerStart/outerStartAdjustedRadius;const outerEndAdjustedAngle=endAngle-outerEnd/outerEndAdjustedRadius;const innerStartAdjustedRadius=innerRadius+innerStart;const innerEndAdjustedRadius=innerRadius+innerEnd;const innerStartAdjustedAngle=startAngle+innerStart/innerStartAdjustedRadius;const innerEndAdjustedAngle=endAngle-innerEnd/innerEndAdjustedRadius;ctx.beginPath();if(circular){ctx.arc(x,y,outerRadius,outerStartAdjustedAngle,outerEndAdjustedAngle);if(outerEnd>0){const pCenter=rThetaToXY(outerEndAdjustedRadius,outerEndAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,outerEnd,outerEndAdjustedAngle,endAngle+HALF_PI);} const p4=rThetaToXY(innerEndAdjustedRadius,endAngle,x,y);ctx.lineTo(p4.x,p4.y);if(innerEnd>0){const pCenter=rThetaToXY(innerEndAdjustedRadius,innerEndAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,innerEnd,endAngle+HALF_PI,innerEndAdjustedAngle+Math.PI);} ctx.arc(x,y,innerRadius,endAngle-(innerEnd/innerRadius),startAngle+(innerStart/innerRadius),true);if(innerStart>0){const pCenter=rThetaToXY(innerStartAdjustedRadius,innerStartAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,innerStart,innerStartAdjustedAngle+Math.PI,startAngle-HALF_PI);} -const p8=rThetaToXY(outerStartAdjustedRadius,startAngle,x,y);ctx.lineTo(p8.x,p8.y);if(outerStart>0){const pCenter=rThetaToXY(outerStartAdjustedRadius,outerStartAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,outerStart,startAngle-HALF_PI,outerStartAdjustedAngle);} +const p8=rThetaToXY(outerStartAdjustedRadius,startAngle,x,y);ctx.lineTo(p8.x,p8.y);if(outerStart>0){const pCenter=rThetaToXY(outerStartAdjustedRadius,outerStartAdjustedAngle,x,y);ctx.arc(pCenter.x,pCenter.y,outerStart,startAngle-HALF_PI,outerStartAdjustedAngle);}}else{ctx.moveTo(x,y);const outerStartX=Math.cos(outerStartAdjustedAngle)*outerRadius+x;const outerStartY=Math.sin(outerStartAdjustedAngle)*outerRadius+y;ctx.lineTo(outerStartX,outerStartY);const outerEndX=Math.cos(outerEndAdjustedAngle)*outerRadius+x;const outerEndY=Math.sin(outerEndAdjustedAngle)*outerRadius+y;ctx.lineTo(outerEndX,outerEndY);} ctx.closePath();} -function drawArc(ctx,element,offset,spacing){const{fullCircles,startAngle,circumference}=element;let endAngle=element.endAngle;if(fullCircles){pathArc(ctx,element,offset,spacing,startAngle+TAU);for(let i=0;i=TAU||_angleBetween(angle,startAngle,endAngle);const withinRadius=_isBetween(distance,innerRadius+rAdjust,outerRadius+rAdjust);return(betweenAngles&&withinRadius);} getCenterPoint(useFinalPosition){const{x,y,startAngle,endAngle,innerRadius,outerRadius}=this.getProps(['x','y','startAngle','endAngle','innerRadius','outerRadius','circumference',],useFinalPosition);const{offset,spacing}=this.options;const halfAngle=(startAngle+endAngle)/2;const halfRadius=(innerRadius+outerRadius+spacing+offset)/2;return{x:x+Math.cos(halfAngle)*halfRadius,y:y+Math.sin(halfAngle)*halfRadius};} tooltipPosition(useFinalPosition){return this.getCenterPoint(useFinalPosition);} -draw(ctx){const{options,circumference}=this;const offset=(options.offset||0)/2;const spacing=(options.spacing||0)/2;this.pixelMargin=(options.borderAlign==='inner')?0.33:0;this.fullCircles=circumference>TAU?Math.floor(circumference/TAU):0;if(circumference===0||this.innerRadius<0||this.outerRadius<0){return;} +draw(ctx){const{options,circumference}=this;const offset=(options.offset||0)/2;const spacing=(options.spacing||0)/2;const circular=options.circular;this.pixelMargin=(options.borderAlign==='inner')?0.33:0;this.fullCircles=circumference>TAU?Math.floor(circumference/TAU):0;if(circumference===0||this.innerRadius<0||this.outerRadius<0){return;} ctx.save();let radiusOffset=0;if(offset){radiusOffset=offset/2;const halfAngle=(this.startAngle+this.endAngle)/2;ctx.translate(Math.cos(halfAngle)*radiusOffset,Math.sin(halfAngle)*radiusOffset);if(this.circumference>=PI){radiusOffset=offset;}} -ctx.fillStyle=options.backgroundColor;ctx.strokeStyle=options.borderColor;const endAngle=drawArc(ctx,this,radiusOffset,spacing);drawBorder(ctx,this,radiusOffset,spacing,endAngle);ctx.restore();}} -ArcElement.id='arc';ArcElement.defaults={borderAlign:'center',borderColor:'#fff',borderJoinStyle:undefined,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:undefined,};ArcElement.defaultRoutes={backgroundColor:'backgroundColor'};function setStyle(ctx,options,style=options){ctx.lineCap=valueOrDefault(style.borderCapStyle,options.borderCapStyle);ctx.setLineDash(valueOrDefault(style.borderDash,options.borderDash));ctx.lineDashOffset=valueOrDefault(style.borderDashOffset,options.borderDashOffset);ctx.lineJoin=valueOrDefault(style.borderJoinStyle,options.borderJoinStyle);ctx.lineWidth=valueOrDefault(style.borderWidth,options.borderWidth);ctx.strokeStyle=valueOrDefault(style.borderColor,options.borderColor);} +ctx.fillStyle=options.backgroundColor;ctx.strokeStyle=options.borderColor;const endAngle=drawArc(ctx,this,radiusOffset,spacing,circular);drawBorder(ctx,this,radiusOffset,spacing,endAngle,circular);ctx.restore();}} +ArcElement.id='arc';ArcElement.defaults={borderAlign:'center',borderColor:'#fff',borderJoinStyle:undefined,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:undefined,circular:true,};ArcElement.defaultRoutes={backgroundColor:'backgroundColor'};function setStyle(ctx,options,style=options){ctx.lineCap=valueOrDefault(style.borderCapStyle,options.borderCapStyle);ctx.setLineDash(valueOrDefault(style.borderDash,options.borderDash));ctx.lineDashOffset=valueOrDefault(style.borderDashOffset,options.borderDashOffset);ctx.lineJoin=valueOrDefault(style.borderJoinStyle,options.borderJoinStyle);ctx.lineWidth=valueOrDefault(style.borderWidth,options.borderWidth);ctx.strokeStyle=valueOrDefault(style.borderColor,options.borderColor);} function lineTo(ctx,previous,target){ctx.lineTo(target.x,target.y);} function getLineMethod(options){if(options.stepped){return _steppedLineTo;} if(options.tension||options.cubicInterpolationMode==='monotone'){return _bezierCurveTo;} @@ -2409,7 +2427,7 @@ hitbox.top=top;hitbox.left+=this.left+padding;hitbox.left=rtlHelper.leftForLtr(r isHorizontal(){return this.options.position==='top'||this.options.position==='bottom';} draw(){if(this.options.display){const ctx=this.ctx;clipArea(ctx,this);this._draw();unclipArea(ctx);}} _draw(){const{options:opts,columnSizes,lineWidths,ctx}=this;const{align,labels:labelOpts}=opts;const defaultColor=defaults.color;const rtlHelper=getRtlAdapter(opts.rtl,this.left,this.width);const labelFont=toFont(labelOpts.font);const{color:fontColor,padding}=labelOpts;const fontSize=labelFont.size;const halfFontSize=fontSize/2;let cursor;this.drawTitle();ctx.textAlign=rtlHelper.textAlign('left');ctx.textBaseline='middle';ctx.lineWidth=0.5;ctx.font=labelFont.string;const{boxWidth,boxHeight,itemHeight}=getBoxSize(labelOpts,fontSize);const drawLegendBox=function(x,y,legendItem){if(isNaN(boxWidth)||boxWidth<=0||isNaN(boxHeight)||boxHeight<0){return;} -ctx.save();const lineWidth=valueOrDefault(legendItem.lineWidth,1);ctx.fillStyle=valueOrDefault(legendItem.fillStyle,defaultColor);ctx.lineCap=valueOrDefault(legendItem.lineCap,'butt');ctx.lineDashOffset=valueOrDefault(legendItem.lineDashOffset,0);ctx.lineJoin=valueOrDefault(legendItem.lineJoin,'miter');ctx.lineWidth=lineWidth;ctx.strokeStyle=valueOrDefault(legendItem.strokeStyle,defaultColor);ctx.setLineDash(valueOrDefault(legendItem.lineDash,[]));if(labelOpts.usePointStyle){const drawOptions={radius:boxHeight*Math.SQRT2/2,pointStyle:legendItem.pointStyle,rotation:legendItem.rotation,borderWidth:lineWidth};const centerX=rtlHelper.xPlus(x,boxWidth/2);const centerY=y+halfFontSize;drawPointLegend(ctx,drawOptions,centerX,centerY,boxWidth);}else{const yBoxTop=y+Math.max((fontSize-boxHeight)/2,0);const xBoxLeft=rtlHelper.leftForLtr(x,boxWidth);const borderRadius=toTRBLCorners(legendItem.borderRadius);ctx.beginPath();if(Object.values(borderRadius).some(v=>v!==0)){addRoundedRectPath(ctx,{x:xBoxLeft,y:yBoxTop,w:boxWidth,h:boxHeight,radius:borderRadius,});}else{ctx.rect(xBoxLeft,yBoxTop,boxWidth,boxHeight);} +ctx.save();const lineWidth=valueOrDefault(legendItem.lineWidth,1);ctx.fillStyle=valueOrDefault(legendItem.fillStyle,defaultColor);ctx.lineCap=valueOrDefault(legendItem.lineCap,'butt');ctx.lineDashOffset=valueOrDefault(legendItem.lineDashOffset,0);ctx.lineJoin=valueOrDefault(legendItem.lineJoin,'miter');ctx.lineWidth=lineWidth;ctx.strokeStyle=valueOrDefault(legendItem.strokeStyle,defaultColor);ctx.setLineDash(valueOrDefault(legendItem.lineDash,[]));if(labelOpts.usePointStyle){const drawOptions={radius:boxHeight*Math.SQRT2/2,pointStyle:legendItem.pointStyle,rotation:legendItem.rotation,borderWidth:lineWidth};const centerX=rtlHelper.xPlus(x,boxWidth/2);const centerY=y+halfFontSize;drawPointLegend(ctx,drawOptions,centerX,centerY,labelOpts.pointStyleWidth&&boxWidth);}else{const yBoxTop=y+Math.max((fontSize-boxHeight)/2,0);const xBoxLeft=rtlHelper.leftForLtr(x,boxWidth);const borderRadius=toTRBLCorners(legendItem.borderRadius);ctx.beginPath();if(Object.values(borderRadius).some(v=>v!==0)){addRoundedRectPath(ctx,{x:xBoxLeft,y:yBoxTop,w:boxWidth,h:boxHeight,radius:borderRadius,});}else{ctx.rect(xBoxLeft,yBoxTop,boxWidth,boxHeight);} ctx.fill();if(lineWidth!==0){ctx.stroke();}} ctx.restore();};const fillText=function(x,y,legendItem){renderText(ctx,legendItem.text,x,y+(itemHeight/2),labelFont,{strikethrough:legendItem.hidden,textAlign:rtlHelper.textAlign(legendItem.textAlign)});};const isHorizontal=this.isHorizontal();const titleHeight=this._computeTitleHeight();if(isHorizontal){cursor={x:_alignStartEnd(align,this.left+padding,this.right-lineWidths[0]),y:this.top+padding+titleHeight,line:0};}else{cursor={x:this.left+padding,y:_alignStartEnd(align,this.top+titleHeight+padding,this.bottom-columnSizes[0].height),line:0};} overrideTextDirection(this.ctx,opts.textDirection);const lineHeight=itemHeight+padding;this.legendItems.forEach((legendItem,i)=>{ctx.strokeStyle=legendItem.fontColor||fontColor;ctx.fillStyle=legendItem.fontColor||fontColor;const textWidth=ctx.measureText(legendItem.text).width;const textAlign=rtlHelper.textAlign(legendItem.textAlign||(legendItem.textAlign=labelOpts.textAlign));const width=boxWidth+halfFontSize+textWidth;let x=cursor.x;let y=cursor.y;rtlHelper.setWidth(this.width);if(isHorizontal){if(i>0&&x+width+padding>this.right){y=cursor.y+=lineHeight;cursor.line++;x=cursor.x=_alignStartEnd(align,this.left+padding,this.right-lineWidths[cursor.line]);}}else if(i>0&&y+lineHeight>this.bottom){x=cursor.x=x+columnSizes[cursor.line].width+padding;cursor.line++;y=cursor.y=_alignStartEnd(align,this.top+titleHeight+padding,this.bottom-columnSizes[cursor.line].height);} @@ -2681,7 +2699,7 @@ return ticks;} function ticksFromTimestamps(scale,values,majorUnit){const ticks=[];const map={};const ilen=values.length;let i,value;for(i=0;i","?",",",".","0","1","2","3","4","5","6","7","8","9"];var isRTL=function(value){for(var i=0;i{return{current:null,history:null,setCurrent:function(currentConnections){var scope=this;scope.current=currentConnections;return scope.current;},setHistory:function(history){var scope=this;scope.history=history;return scope.history;}};},true,true);})(window);window.ls.router.add("/auth/signin",{template:"/auth/signin?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/signup",{template:"/auth/signup?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/recovery",{template:"/auth/recovery?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/recovery/reset",{template:"/auth/recovery/reset?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/confirm",{template:"/auth/confirm?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/join",{template:"/auth/join?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/magic-url",{template:"/auth/magic-url?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/oauth2/success",{template:"/auth/oauth2/success?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/oauth2/failure",{template:"/auth/oauth2/failure?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/console",{template:"/console?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/account",{template:"/console/account?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/account/:tab",{template:"/console/account?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/home",{template:"/console/home?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/home/:tab",{template:"/console/home?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/platforms/:platform",{template:function(window){return window.location.pathname+"?version="+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/notifications",{template:"/console/notifications?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/settings",{template:"/console/settings?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/settings/:tab",{template:"/console/settings?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/webhooks",{template:"/console/webhooks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/webhooks/webhook",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/webhooks/webhook/new",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/keys",{template:"/console/keys?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/keys/:tab",{template:"/console/keys?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/tasks",{template:"/console/tasks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/tasks/:tab",{template:"/console/tasks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/databases",{template:"/console/databases?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/databases/database",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/database/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/usage",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/collection",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/collection/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/document",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/document/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage",{template:"/console/storage?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/storage/usage",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage/bucket",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage/bucket/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/users",{template:"/console/users?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/user",{template:"/console/users/user?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/user/:tab",{template:"/console/users/user?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/teams/team",{template:"/console/users/teams/team?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/teams/team/:tab",{template:"/console/users/teams/team?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/:tab",{template:"/console/users?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions",{template:"/console/functions?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/function",{template:"/console/functions/function?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/function/:tab",{template:"/console/functions/function?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/:tab",{template:"/console/functions?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true});window.ls.filter.add("avatar",function($value,element){if(!$value){return"";} +return false;};return{isRTL:isRTL,};},true);})(window);(function(window){"use strict";window.ls.container.set('sdk',function(window,router){var client=new Appwrite.Client();var endpoint=window.location.origin+'/v1';client.setEndpoint(endpoint).setProject(router.params.project||'').setLocale(APP_ENV.LOCALE).setMode('admin');return{client:client,account:new Appwrite.Account(client),avatars:new Appwrite.Avatars(client),databases:new Appwrite.Databases(client),functions:new Appwrite.Functions(client),health:new Appwrite.Health(client),locale:new Appwrite.Locale(client),storage:new Appwrite.Storage(client),teams:new Appwrite.Teams(client),users:new Appwrite.Users(client)}},false);})(window);(function(window){"use strict";window.ls.container.set('search',function(window){return{params:{},path:'',pointer:'',selected:'',};},true,true);})(window);(function(window){"use strict";window.ls.container.set('timezone',function(){return{convert:function(unixTime){var timezoneMinutes=new Date().getTimezoneOffset();timezoneMinutes=(timezoneMinutes===0)?0:-timezoneMinutes;return parseInt(unixTime)+(timezoneMinutes*60);}};},true);})(window);(function(window){"use strict";window.ls.container.set('realtime',()=>{return{current:null,history:null,setCurrent:function(currentConnections){var scope=this;scope.current=currentConnections;return scope.current;},setHistory:function(history){var scope=this;scope.history=history;return scope.history;}};},true,true);})(window);window.ls.router.add("/auth/signin",{template:"/auth/signin?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/signup",{template:"/auth/signup?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/recovery",{template:"/auth/recovery?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/recovery/reset",{template:"/auth/recovery/reset?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/confirm",{template:"/auth/confirm?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/join",{template:"/auth/join?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/magic-url",{template:"/auth/magic-url?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/oauth2/success",{template:"/auth/oauth2/success?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/auth/oauth2/failure",{template:"/auth/oauth2/failure?version="+APP_ENV.CACHEBUSTER,scope:"home"}).add("/console",{template:"/console?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/account",{template:"/console/account?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/account/:tab",{template:"/console/account?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/home",{template:"/console/home?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/home/:tab",{template:"/console/home?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/platforms/:platform",{template:function(window){return window.location.pathname+"?version="+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/notifications",{template:"/console/notifications?version="+APP_ENV.CACHEBUSTER,scope:"console"}).add("/console/settings",{template:"/console/settings?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/settings/:tab",{template:"/console/settings?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/webhooks",{template:"/console/webhooks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/webhooks/webhook",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/webhooks/webhook/new",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/keys",{template:"/console/keys?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/keys/:tab",{template:"/console/keys?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/tasks",{template:"/console/tasks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/tasks/:tab",{template:"/console/tasks?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/databases",{template:"/console/databases?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/databases/database",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/database/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/usage",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/collection",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/collection/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/document",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/databases/document/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage",{template:"/console/storage?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/storage/usage",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage/bucket",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/storage/bucket/:tab",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/users",{template:"/console/users?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/user",{template:"/console/users/user?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/user/:tab",{template:"/console/users/user?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/teams/team",{template:"/console/users/teams/team?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/teams/team/:tab",{template:"/console/users/teams/team?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/users/:tab",{template:"/console/users?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions",{template:"/console/functions?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/usage",{template:function(window){return window.location.pathname+window.location.search+'&version='+APP_ENV.CACHEBUSTER;},scope:"console",project:true}).add("/console/functions/function",{template:"/console/functions/function?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/function/:tab",{template:"/console/functions/function?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true}).add("/console/functions/:tab",{template:"/console/functions?version="+APP_ENV.CACHEBUSTER,scope:"console",project:true});window.ls.filter.add("avatar",function($value,element){if(!$value){return"";} let size=element.dataset["size"]||80;let name=$value.name||$value||"";name=(typeof name!=='string')?'--':name;return def="/v1/avatars/initials?project=console"+"&name="+ encodeURIComponent(name)+"&width="+ size+"&height="+ diff --git a/public/scripts/dependencies/appwrite.js b/public/scripts/dependencies/appwrite.js index fabd6d57c7..3fac48d7dc 100644 --- a/public/scripts/dependencies/appwrite.js +++ b/public/scripts/dependencies/appwrite.js @@ -643,23 +643,23 @@ * /account/verification/phone](/docs/client/account#accountCreatePhoneVerification) * endpoint to send a confirmation SMS. * - * @param {string} number + * @param {string} phone * @param {string} password * @throws {AppwriteException} * @returns {Promise} */ - updatePhone(number, password) { + updatePhone(phone, password) { return __awaiter(this, void 0, void 0, function* () { - if (typeof number === 'undefined') { - throw new AppwriteException('Missing required parameter: "number"'); + if (typeof phone === 'undefined') { + throw new AppwriteException('Missing required parameter: "phone"'); } if (typeof password === 'undefined') { throw new AppwriteException('Missing required parameter: "password"'); } let path = '/account/phone'; let payload = {}; - if (typeof number !== 'undefined') { - payload['number'] = number; + if (typeof phone !== 'undefined') { + payload['phone'] = phone; } if (typeof password !== 'undefined') { payload['password'] = password; @@ -1051,25 +1051,25 @@ * is valid for 15 minutes. * * @param {string} userId - * @param {string} number + * @param {string} phone * @throws {AppwriteException} * @returns {Promise} */ - createPhoneSession(userId, number) { + createPhoneSession(userId, phone) { return __awaiter(this, void 0, void 0, function* () { if (typeof userId === 'undefined') { throw new AppwriteException('Missing required parameter: "userId"'); } - if (typeof number === 'undefined') { - throw new AppwriteException('Missing required parameter: "number"'); + if (typeof phone === 'undefined') { + throw new AppwriteException('Missing required parameter: "phone"'); } let path = '/account/sessions/phone'; let payload = {}; if (typeof userId !== 'undefined') { payload['userId'] = userId; } - if (typeof number !== 'undefined') { - payload['number'] = number; + if (typeof phone !== 'undefined') { + payload['phone'] = phone; } const uri = new URL(this.client.config.endpoint + path); return yield this.client.call('post', uri, { @@ -1078,7 +1078,7 @@ }); } /** - * Create Phone session (confirmation) + * Create Phone Session (confirmation) * * Use this endpoint to complete creating a session with SMS. Use the * **userId** from the @@ -3187,6 +3187,27 @@ }, payload); }); } + /** + * Get Functions Usage + * + * + * @param {string} range + * @throws {AppwriteException} + * @returns {Promise} + */ + getUsage(range) { + return __awaiter(this, void 0, void 0, function* () { + let path = '/functions/usage'; + let payload = {}; + if (typeof range !== 'undefined') { + payload['range'] = range; + } + const uri = new URL(this.client.config.endpoint + path); + return yield this.client.call('get', uri, { + 'content-type': 'application/json', + }, payload); + }); + } /** * Get Function * @@ -3641,7 +3662,7 @@ * @throws {AppwriteException} * @returns {Promise} */ - getUsage(functionId, range) { + getFunctionUsage(functionId, range) { return __awaiter(this, void 0, void 0, function* () { if (typeof functionId === 'undefined') { throw new AppwriteException('Missing required parameter: "functionId"'); @@ -6163,22 +6184,17 @@ * * @param {string} userId * @param {string} email + * @param {string} phone * @param {string} password * @param {string} name * @throws {AppwriteException} * @returns {Promise} */ - create(userId, email, password, name) { + create(userId, email, phone, password, name) { return __awaiter(this, void 0, void 0, function* () { if (typeof userId === 'undefined') { throw new AppwriteException('Missing required parameter: "userId"'); } - if (typeof email === 'undefined') { - throw new AppwriteException('Missing required parameter: "email"'); - } - if (typeof password === 'undefined') { - throw new AppwriteException('Missing required parameter: "password"'); - } let path = '/users'; let payload = {}; if (typeof userId !== 'undefined') { @@ -6187,6 +6203,9 @@ if (typeof email !== 'undefined') { payload['email'] = email; } + if (typeof phone !== 'undefined') { + payload['phone'] = phone; + } if (typeof password !== 'undefined') { payload['password'] = password; } diff --git a/public/scripts/routes.js b/public/scripts/routes.js index 92dbe3ad75..4d7e0fb77f 100644 --- a/public/scripts/routes.js +++ b/public/scripts/routes.js @@ -232,6 +232,13 @@ window.ls.router scope: "console", project: true }) + .add("/console/functions/usage", { + template: function(window) { + return window.location.pathname + window.location.search + '&version=' + APP_ENV.CACHEBUSTER; + }, + scope: "console", + project: true + }) .add("/console/functions/function", { template: "/console/functions/function?version=" + APP_ENV.CACHEBUSTER, scope: "console", diff --git a/src/Appwrite/Permissions/PermissionsProcessor.php b/src/Appwrite/Permissions/PermissionsProcessor.php deleted file mode 100644 index 7a7188498e..0000000000 --- a/src/Appwrite/Permissions/PermissionsProcessor.php +++ /dev/null @@ -1,62 +0,0 @@ - $permission) { - $permission = Permission::parse($permission); - foreach ($aggregates as $type => $subTypes) { - if ($permission->getPermission() != $type) { - continue; - } - foreach ($subTypes as $subType) { - $permissions[] = (new Permission( - $subType, - $permission->getRole(), - $permission->getIdentifier(), - $permission->getDimension() - ))->toString(); - } - unset($permissions[$i]); - } - } - return $permissions; - } - - private static function getAggregates($resource): array - { - $aggregates = []; - - switch ($resource) { - case 'document': - case 'file': - $aggregates['write'] = [ - Database::PERMISSION_UPDATE, - Database::PERMISSION_DELETE - ]; - break; - case 'collection': - case 'bucket': - $aggregates['write'] = [ - Database::PERMISSION_CREATE, - Database::PERMISSION_UPDATE, - Database::PERMISSION_DELETE - ]; - break; - } - - return $aggregates; - } -} diff --git a/src/Appwrite/Specification/Format/OpenAPI3.php b/src/Appwrite/Specification/Format/OpenAPI3.php index a9ac79e0c8..c7a9db0524 100644 --- a/src/Appwrite/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/Specification/Format/OpenAPI3.php @@ -5,6 +5,8 @@ namespace Appwrite\Specification\Format; use Appwrite\Specification\Format; use Appwrite\Template\Template; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Permission; +use Utopia\Database\Role; use Utopia\Validator; class OpenAPI3 extends Format @@ -338,6 +340,14 @@ class OpenAPI3 extends Format $node['schema']['items'] = [ 'type' => 'string', ]; + $node['schema']['x-example'] = '["' . Permission::read(Role::any()) . '"]'; + break; + case 'Utopia\Database\Validator\Roles': + $node['schema']['type'] = $validator->getType(); + $node['schema']['items'] = [ + 'type' => 'string', + ]; + $node['schema']['x-example'] = '["' . Role::any()->toString() . '"]'; break; case 'Appwrite\Auth\Validator\Password': $node['schema']['type'] = $validator->getType(); diff --git a/src/Appwrite/Stats/Stats.php b/src/Appwrite/Stats/Stats.php deleted file mode 100644 index e1e5f0f0d4..0000000000 --- a/src/Appwrite/Stats/Stats.php +++ /dev/null @@ -1,200 +0,0 @@ -statsd = $statsd; - } - - /** - * @param string $key - * @param mixed $value - * - * @return $this - */ - public function setParam(string $key, $value): self - { - $this->params[$key] = $value; - - return $this; - } - - /** - * @param string $key - * - * @return mixed|null - */ - public function getParam(string $key) - { - return (isset($this->params[$key])) ? $this->params[$key] : null; - } - - /** - * @param string $namespace - * - * @return $this - */ - public function setNamespace(string $namespace): self - { - $this->namespace = $namespace; - - return $this; - } - - /** - * @return string - */ - public function getNamespace() - { - return $this->namespace; - } - - /** - * Submit data to StatsD. - */ - public function submit(): void - { - $projectId = $this->params['projectId'] ?? ''; - - $storage = $this->params['storage'] ?? 0; - - $networkRequestSize = $this->params['networkRequestSize'] ?? 0; - $networkResponseSize = $this->params['networkResponseSize'] ?? 0; - - $httpMethod = $this->params['httpMethod'] ?? ''; - $httpRequest = $this->params['httpRequest'] ?? 0; - - $functionId = $this->params['functionId'] ?? ''; - $functionExecution = $this->params['functionExecution'] ?? 0; - $functionExecutionTime = $this->params['functionExecutionTime'] ?? 0; - $functionStatus = $this->params['functionStatus'] ?? ''; - - $tags = ",projectId={$projectId},version=" . App::getEnv('_APP_VERSION', 'UNKNOWN'); - - // the global namespace is prepended to every key (optional) - $this->statsd->setNamespace($this->namespace); - - if ($httpRequest >= 1) { - $this->statsd->increment('requests.all' . $tags . ',method=' . \strtolower($httpMethod)); - } - - if ($functionExecution >= 1) { - $this->statsd->increment('executions.all' . $tags . ',functionId=' . $functionId . ',functionStatus=' . $functionStatus); - $this->statsd->count('executions.time' . $tags . ',functionId=' . $functionId, $functionExecutionTime); - } - - $this->statsd->count('network.inbound' . $tags, $networkRequestSize); - $this->statsd->count('network.outbound' . $tags, $networkResponseSize); - $this->statsd->count('network.all' . $tags, $networkRequestSize + $networkResponseSize); - - $dbMetrics = [ - 'databases.create', - 'databases.read', - 'databases.update', - 'databases.delete', - 'databases.collections.create', - 'databases.collections.read', - 'databases.collections.update', - 'databases.collections.delete', - 'databases.documents.create', - 'databases.documents.read', - 'databases.documents.update', - 'databases.documents.delete', - ]; - - foreach ($dbMetrics as $metric) { - $value = $this->params[$metric] ?? 0; - if ($value >= 1) { - $tags = ",projectId={$projectId},collectionId=" . ($this->params['collectionId'] ?? '') . ",databaseId=" . ($this->params['databaseId'] ?? ''); - $this->statsd->increment($metric . $tags); - } - } - - $storageMertics = [ - 'storage.buckets.create', - 'storage.buckets.read', - 'storage.buckets.update', - 'storage.buckets.delete', - 'storage.files.create', - 'storage.files.read', - 'storage.files.update', - 'storage.files.delete', - ]; - - foreach ($storageMertics as $metric) { - $value = $this->params[$metric] ?? 0; - if ($value >= 1) { - $tags = ",projectId={$projectId},bucketId=" . ($this->params['bucketId'] ?? ''); - $this->statsd->increment($metric . $tags); - } - } - - $usersMetrics = [ - 'users.create', - 'users.read', - 'users.update', - 'users.delete', - ]; - - foreach ($usersMetrics as $metric) { - $value = $this->params[$metric] ?? 0; - if ($value >= 1) { - $tags = ",projectId={$projectId}"; - $this->statsd->increment($metric . $tags); - } - } - - $sessionsMetrics = [ - 'users.sessions.create', - 'users.sessions.delete', - ]; - - foreach ($sessionsMetrics as $metric) { - $value = $this->params[$metric] ?? 0; - if ($value >= 1) { - $tags = ",projectId={$projectId},provider=" . ($this->params['provider'] ?? ''); - $this->statsd->count($metric . $tags, $value); - } - } - - if ($storage >= 1) { - $tags = ",projectId={$projectId},bucketId=" . ($this->params['bucketId'] ?? ''); - $this->statsd->count('storage.all' . $tags, $storage); - } - - $this->reset(); - } - - public function reset(): self - { - $this->params = []; - $this->namespace = 'appwrite.usage'; - - return $this; - } -} diff --git a/src/Appwrite/Stats/Usage.php b/src/Appwrite/Stats/Usage.php deleted file mode 100644 index 31daeca3a0..0000000000 --- a/src/Appwrite/Stats/Usage.php +++ /dev/null @@ -1,358 +0,0 @@ - [ - 'table' => 'appwrite_usage_requests_all', - ], - 'network' => [ - 'table' => 'appwrite_usage_network_all', - ], - 'executions' => [ - 'table' => 'appwrite_usage_executions_all', - ], - 'databases.create' => [ - 'table' => 'appwrite_usage_databases_create', - ], - 'databases.read' => [ - 'table' => 'appwrite_usage_databases_read', - ], - 'databases.update' => [ - 'table' => 'appwrite_usage_databases_update', - ], - 'databases.delete' => [ - 'table' => 'appwrite_usage_databases_delete', - ], - 'databases.collections.create' => [ - 'table' => 'appwrite_usage_databases_collections_create', - ], - 'databases.collections.read' => [ - 'table' => 'appwrite_usage_databases_collections_read', - ], - 'databases.collections.update' => [ - 'table' => 'appwrite_usage_databases_collections_update', - ], - 'databases.collections.delete' => [ - 'table' => 'appwrite_usage_databases_collections_delete', - ], - 'databases.documents.create' => [ - 'table' => 'appwrite_usage_databases_documents_create', - ], - 'databases.documents.read' => [ - 'table' => 'appwrite_usage_databases_documents_read', - ], - 'databases.documents.update' => [ - 'table' => 'appwrite_usage_databases_documents_update', - ], - 'databases.documents.delete' => [ - 'table' => 'appwrite_usage_databases_documents_delete', - ], - 'databases.databaseId.collections.create' => [ - 'table' => 'appwrite_usage_databases_collections_create', - 'groupBy' => ['databaseId'], - ], - 'databases.databaseId.collections.read' => [ - 'table' => 'appwrite_usage_databases_collections_read', - 'groupBy' => ['databaseId'], - ], - 'databases.databaseId.collections.update' => [ - 'table' => 'appwrite_usage_databases_collections_update', - 'groupBy' => ['databaseId'], - ], - 'databases.databaseId.collections.delete' => [ - 'table' => 'appwrite_usage_databases_collections_delete', - 'groupBy' => ['databaseId'], - ], - 'databases.databaseId.documents.create' => [ - 'table' => 'appwrite_usage_databases_documents_create', - 'groupBy' => ['databaseId'], - ], - 'databases.databaseId.documents.read' => [ - 'table' => 'appwrite_usage_databases_documents_read', - 'groupBy' => ['databaseId'], - ], - 'database.databaseId.documents.update' => [ - 'table' => 'appwrite_usage_databases_documents_update', - 'groupBy' => ['databaseId'], - ], - 'databases.databaseId.documents.delete' => [ - 'table' => 'appwrite_usage_databases_documents_delete', - 'groupBy' => ['databaseId'], - ], - 'databases.databaseId.collections.collectionId.documents.create' => [ - 'table' => 'appwrite_usage_databases_documents_create', - 'groupBy' => ['databaseId', 'collectionId'], - ], - 'databases.databaseId.collections.collectionId.documents.read' => [ - 'table' => 'appwrite_usage_databases_documents_read', - 'groupBy' => ['databaseId', 'collectionId'], - ], - 'databases.databaseId.collections.collectionId.documents.update' => [ - 'table' => 'appwrite_usage_databases_documents_update', - 'groupBy' => ['databaseId', 'collectionId'], - ], - 'databases.databaseId.collections.collectionId.documents.delete' => [ - 'table' => 'appwrite_usage_databases_documents_delete', - 'groupBy' => ['databaseId', 'collectionId'], - ], - 'storage.buckets.create' => [ - 'table' => 'appwrite_usage_storage_buckets_create', - ], - 'storage.buckets.read' => [ - 'table' => 'appwrite_usage_storage_buckets_read', - ], - 'storage.buckets.update' => [ - 'table' => 'appwrite_usage_storage_buckets_update', - ], - 'storage.buckets.delete' => [ - 'table' => 'appwrite_usage_storage_buckets_delete', - ], - 'storage.files.create' => [ - 'table' => 'appwrite_usage_storage_files_create', - ], - 'storage.files.read' => [ - 'table' => 'appwrite_usage_storage_files_read', - ], - 'storage.files.update' => [ - 'table' => 'appwrite_usage_storage_files_update', - ], - 'storage.files.delete' => [ - 'table' => 'appwrite_usage_storage_files_delete', - ], - 'storage.buckets.bucketId.files.create' => [ - 'table' => 'appwrite_usage_storage_files_create', - 'groupBy' => ['bucketId'], - ], - 'storage.buckets.bucketId.files.read' => [ - 'table' => 'appwrite_usage_storage_files_read', - 'groupBy' => ['bucketId'], - ], - 'storage.buckets.bucketId.files.update' => [ - 'table' => 'appwrite_usage_storage_files_update', - 'groupBy' => ['bucketId'], - ], - 'storage.buckets.bucketId.files.delete' => [ - 'table' => 'appwrite_usage_storage_files_delete', - 'groupBy' => ['bucketId'], - ], - 'users.create' => [ - 'table' => 'appwrite_usage_users_create', - ], - 'users.read' => [ - 'table' => 'appwrite_usage_users_read', - ], - 'users.update' => [ - 'table' => 'appwrite_usage_users_update', - ], - 'users.delete' => [ - 'table' => 'appwrite_usage_users_delete', - ], - 'users.sessions.create' => [ - 'table' => 'appwrite_usage_users_sessions_create', - ], - 'users.sessions.provider.create' => [ - 'table' => 'appwrite_usage_users_sessions_create', - 'groupBy' => ['provider'], - ], - 'users.sessions.delete' => [ - 'table' => 'appwrite_usage_users_sessions_delete', - ], - 'functions.functionId.executions' => [ - 'table' => 'appwrite_usage_executions_all', - 'groupBy' => ['functionId'], - ], - 'functions.functionId.compute' => [ - 'table' => 'appwrite_usage_executions_time', - 'groupBy' => ['functionId'], - ], - 'functions.functionId.failures' => [ - 'table' => 'appwrite_usage_executions_all', - 'groupBy' => ['functionId'], - 'filters' => [ - 'functionStatus' => 'failed', - ], - ], - ]; - - protected array $periods = [ - [ - 'key' => '30m', - 'startTime' => '-24 hours', - ], - [ - 'key' => '1d', - 'startTime' => '-90 days', - ], - ]; - - public function __construct(Database $database, InfluxDatabase $influxDB, callable $errorHandler = null) - { - $this->database = $database; - $this->influxDB = $influxDB; - $this->errorHandler = $errorHandler; - } - - /** - * Create or Update Mertic - * Create or update each metric in the stats collection for the given project - * - * @param string $projectId - * @param string $time - * @param string $period - * @param string $metric - * @param int $value - * @param int $type - * - * @return void - */ - private function createOrUpdateMetric(string $projectId, string $time, string $period, string $metric, int $value, int $type): void - { - $id = \md5("{$time}_{$period}_{$metric}"); - $this->database->setNamespace('_console'); - $project = $this->database->getDocument('projects', $projectId); - $this->database->setNamespace('_' . $project->getInternalId()); - - try { - $document = $this->database->getDocument('stats', $id); - if ($document->isEmpty()) { - $this->database->createDocument('stats', new Document([ - '$id' => $id, - 'period' => $period, - 'time' => $time, - 'metric' => $metric, - 'value' => $value, - 'type' => $type, - ])); - } else { - $this->database->updateDocument( - 'stats', - $document->getId(), - $document->setAttribute('value', $value) - ); - } - - $time = (new \DateTime($time))->getTimestamp(); //todo: What about this timestamp? - $this->latestTime[$metric][$period] = $time; - } catch (\Exception $e) { // if projects are deleted this might fail - if (is_callable($this->errorHandler)) { - call_user_func($this->errorHandler, $e, "sync_project_{$projectId}_metric_{$metric}"); - } else { - throw $e; - } - } - } - - /** - * Sync From InfluxDB - * Sync stats from influxDB to stats collection in the Appwrite database - * - * @param string $metric - * @param array $options - * @param array $period - * - * @return void - */ - private function syncFromInfluxDB(string $metric, array $options, array $period): void - { - $start = DateTime::createFromFormat('U', \strtotime($period['startTime']))->format(DateTime::RFC3339); - if (!empty($this->latestTime[$metric][$period['key']])) { - $start = DateTime::createFromFormat('U', $this->latestTime[$metric][$period['key']])->format(DateTime::RFC3339); - } - $end = DateTime::createFromFormat('U', \strtotime('now'))->format(DateTime::RFC3339); - - $table = $options['table']; //Which influxdb table to query for this metric - $groupBy = empty($options['groupBy']) ? '' : ', ' . implode(', ', array_map(fn($groupBy) => '"' . $groupBy . '" ', $options['groupBy'])); //Some sub level metrics may be grouped by other tags like collectionId, bucketId, etc - - $filters = $options['filters'] ?? []; // Some metrics might have additional filters, like function's status - if (!empty($filters)) { - $filters = ' AND ' . implode(' AND ', array_map(fn ($filter, $value) => "\"{$filter}\"='{$value}'", array_keys($filters), array_values($filters))); - } else { - $filters = ''; - } - - $query = "SELECT sum(value) AS \"value\" "; - $query .= "FROM \"{$table}\" "; - $query .= "WHERE \"time\" > '{$start}' "; - $query .= "AND \"time\" < '{$end}' "; - $query .= "AND \"metric_type\"='counter' {$filters} "; - $query .= "GROUP BY time({$period['key']}), \"projectId\" {$groupBy} "; - $query .= "FILL(null)"; - - try { - $result = $this->influxDB->query($query); - $points = $result->getPoints(); - foreach ($points as $point) { - $projectId = $point['projectId']; - - if (!empty($projectId) && $projectId !== 'console') { - $metricUpdated = $metric; - - if (!empty($groupBy)) { - foreach ($options['groupBy'] as $groupBy) { - $groupedBy = $point[$groupBy] ?? ''; - if (empty($groupedBy)) { - continue; - } - $metricUpdated = str_replace($groupBy, $groupedBy, $metricUpdated); - } - } - - $time = $point['time']; //todo: check is this datetime format? - $value = (!empty($point['value'])) ? $point['value'] : 0; - - $this->createOrUpdateMetric( - $projectId, - $time, - $period['key'], - $metricUpdated, - $value, - 0 - ); - } - } - } catch (\Exception $e) { // if projects are deleted this might fail - if (is_callable($this->errorHandler)) { - call_user_func($this->errorHandler, $e, "sync_metric_{$metric}_influxdb"); - } else { - throw $e; - } - } - } - - /** - * Collect Stats - * Collect all the stats from Influd DB to Database - * - * @return void - */ - public function collect(): void - { - foreach ($this->metrics as $metric => $options) { //for each metrics - foreach ($this->periods as $period) { // aggregate data for each period - try { - $this->syncFromInfluxDB($metric, $options, $period); - } catch (\Exception $e) { - if (is_callable($this->errorHandler)) { - call_user_func($this->errorHandler, $e); - } else { - throw $e; - } - } - } - } - } -} diff --git a/src/Appwrite/Usage/Calculator.php b/src/Appwrite/Usage/Calculator.php new file mode 100644 index 0000000000..ab9a5e268b --- /dev/null +++ b/src/Appwrite/Usage/Calculator.php @@ -0,0 +1,8 @@ +database->setNamespace('_' . $projectId); + + $databasesGeneralMetrics = [ + 'databases.$all.requests.create', + 'databases.$all.requests.read', + 'databases.$all.requests.update', + 'databases.$all.requests.delete', + 'collections.$all.requests.create', + 'collections.$all.requests.read', + 'collections.$all.requests.update', + 'collections.$all.requests.delete', + 'documents.$all.requests.create', + 'documents.$all.requests.read', + 'documents.$all.requests.update', + 'documents.$all.requests.delete' + ]; + + foreach ($databasesGeneralMetrics as $metric) { + $this->aggregateDailyMetric($projectId, $metric); + $this->aggregateMonthlyMetric($projectId, $metric); + } + + $databasesDatabaseMetrics = [ + 'collections.databaseId.requests.create', + 'collections.databaseId.requests.read', + 'collections.databaseId.requests.update', + 'collections.databaseId.requests.delete', + 'documents.databaseId.requests.create', + 'documents.databaseId.requests.read', + 'documents.databaseId.requests.update', + 'documents.databaseId.requests.delete', + ]; + + $this->foreachDocument($projectId, 'databases', [], function (Document $database) use ($databasesDatabaseMetrics, $projectId) { + $databaseId = $database->getId(); + foreach ($databasesDatabaseMetrics as $metric) { + $metric = str_replace('databaseId', $databaseId, $metric); + $this->aggregateDailyMetric($projectId, $metric); + $this->aggregateMonthlyMetric($projectId, $metric); + } + + $databasesCollectionMetrics = [ + 'documents.' . $databaseId . '/collectionId.requests.create', + 'documents.' . $databaseId . '/collectionId.requests.read', + 'documents.' . $databaseId . '/collectionId.requests.update', + 'documents.' . $databaseId . '/collectionId.requests.delete', + ]; + + $this->foreachDocument($projectId, 'database_' . $database->getInternalId(), [], function (Document $collection) use ($databasesCollectionMetrics, $projectId) { + $collectionId = $collection->getId(); + foreach ($databasesCollectionMetrics as $metric) { + $metric = str_replace('collectionId', $collectionId, $metric); + $this->aggregateDailyMetric($projectId, $metric); + $this->aggregateMonthlyMetric($projectId, $metric); + } + }); + }); + } + + protected function aggregateStorageMetrics(string $projectId): void + { + $this->database->setNamespace('_' . $projectId); + + $storageGeneralMetrics = [ + 'buckets.$all.requests.create', + 'buckets.$all.requests.read', + 'buckets.$all.requests.update', + 'buckets.$all.requests.delete', + 'files.$all.requests.create', + 'files.$all.requests.read', + 'files.$all.requests.update', + 'files.$all.requests.delete', + ]; + + foreach ($storageGeneralMetrics as $metric) { + $this->aggregateDailyMetric($projectId, $metric); + $this->aggregateMonthlyMetric($projectId, $metric); + } + + $storageBucketMetrics = [ + 'files.bucketId.requests.create', + 'files.bucketId.requests.read', + 'files.bucketId.requests.update', + 'files.bucketId.requests.delete', + ]; + + $this->foreachDocument($projectId, 'buckets', [], function (Document $bucket) use ($storageBucketMetrics, $projectId) { + $bucketId = $bucket->getId(); + foreach ($storageBucketMetrics as $metric) { + $metric = str_replace('bucketId', $bucketId, $metric); + $this->aggregateDailyMetric($projectId, $metric); + $this->aggregateMonthlyMetric($projectId, $metric); + } + }); + } + + protected function aggregateFunctionMetrics(string $projectId): void + { + $this->database->setNamespace('_' . $projectId); + + $functionsGeneralMetrics = [ + 'project.$all.compute.total', + 'project.$all.compute.time', + 'executions.$all.compute.total', + 'executions.$all.compute.success', + 'executions.$all.compute.failure', + 'executions.$all.compute.time', + 'builds.$all.compute.total', + 'builds.$all.compute.success', + 'builds.$all.compute.failure', + 'builds.$all.compute.time', + ]; + + foreach ($functionsGeneralMetrics as $metric) { + $this->aggregateDailyMetric($projectId, $metric); + $this->aggregateMonthlyMetric($projectId, $metric); + } + + $functionMetrics = [ + 'executions.functionId.compute.total', + 'executions.functionId.compute.success', + 'executions.functionId.compute.failure', + 'executions.functionId.compute.time', + 'builds.functionId.compute.total', + 'builds.functionId.compute.success', + 'builds.functionId.compute.failure', + 'builds.functionId.compute.time', + ]; + + $this->foreachDocument($projectId, 'functions', [], function (Document $function) use ($functionMetrics, $projectId) { + $functionId = $function->getId(); + foreach ($functionMetrics as $metric) { + $metric = str_replace('functionId', $functionId, $metric); + $this->aggregateDailyMetric($projectId, $metric); + $this->aggregateMonthlyMetric($projectId, $metric); + } + }); + } + + protected function aggregateUsersMetrics(string $projectId): void + { + $metrics = [ + 'users.$all.requests.create', + 'users.$all.requests.read', + 'users.$all.requests.update', + 'users.$all.requests.delete', + 'sessions.$all.requests.create', + 'sessions.$all.requests.delete' + ]; + + foreach ($metrics as $metric) { + $this->aggregateDailyMetric($projectId, $metric); + $this->aggregateMonthlyMetric($projectId, $metric); + } + } + + protected function aggregateGeneralMetrics(string $projectId): void + { + $this->aggregateDailyMetric($projectId, 'project.$all.network.requests'); + $this->aggregateDailyMetric($projectId, 'project.$all.network.bandwidth'); + $this->aggregateDailyMetric($projectId, 'project.$all.network.inbound'); + $this->aggregateDailyMetric($projectId, 'project.$all.network.outbound'); + $this->aggregateMonthlyMetric($projectId, 'project.$all.network.requests'); + $this->aggregateMonthlyMetric($projectId, 'project.$all.network.bandwidth'); + $this->aggregateMonthlyMetric($projectId, 'project.$all.network.inbound'); + $this->aggregateMonthlyMetric($projectId, 'project.$all.network.outbound'); + } + + protected function aggregateDailyMetric(string $projectId, string $metric): void + { + $beginOfDay = strtotime("today"); + $endOfDay = strtotime("tomorrow", $beginOfDay) - 1; + $this->database->setNamespace('_' . $projectId); + $value = (int) $this->database->sum('stats', 'value', [ + new Query('metric', Query::TYPE_EQUAL, [$metric]), + new Query('period', Query::TYPE_EQUAL, ['30m']), + new Query('time', Query::TYPE_GREATEREQUAL, [$beginOfDay]), + new Query('time', Query::TYPE_LESSEREQUAL, [$endOfDay]), + ]); + $this->createOrUpdateMetric($projectId, $metric, '1d', $beginOfDay, $value); + } + + protected function aggregateMonthlyMetric(string $projectId, string $metric): void + { + $beginOfMonth = strtotime("first day of the month"); + $endOfMonth = strtotime("last day of the month"); + $this->database->setNamespace('_' . $projectId); + $value = (int) $this->database->sum('stats', 'value', [ + new Query('metric', Query::TYPE_EQUAL, [$metric]), + new Query('period', Query::TYPE_EQUAL, ['1d']), + new Query('time', Query::TYPE_GREATEREQUAL, [$beginOfMonth]), + new Query('time', Query::TYPE_LESSEREQUAL, [$endOfMonth]), + ]); + $this->createOrUpdateMetric($projectId, $metric, '1mo', $beginOfMonth, $value); + } + + /** + * Collect Stats + * Collect all database related stats + * + * @return void + */ + public function collect(): void + { + $this->foreachDocument('console', 'projects', [], function (Document $project) { + $projectId = $project->getInternalId(); + + // Aggregate new metrics from already collected usage metrics + // for lower time period (1day and 1 month metric from 30 minute metrics) + $this->aggregateGeneralMetrics($projectId); + $this->aggregateFunctionMetrics($projectId); + $this->aggregateDatabaseMetrics($projectId); + $this->aggregateStorageMetrics($projectId); + $this->aggregateUsersMetrics($projectId); + }); + } +} diff --git a/src/Appwrite/Stats/UsageDB.php b/src/Appwrite/Usage/Calculators/Database.php similarity index 55% rename from src/Appwrite/Stats/UsageDB.php rename to src/Appwrite/Usage/Calculators/Database.php index fef6b38e7c..8e8642159c 100644 --- a/src/Appwrite/Stats/UsageDB.php +++ b/src/Appwrite/Usage/Calculators/Database.php @@ -1,32 +1,48 @@ '30m', + 'multiplier' => 1800, + ], + [ + 'key' => '1d', + 'multiplier' => 86400, + ], + ]; + + public function __construct(UtopiaDatabase $database, callable $errorHandler = null) { $this->database = $database; $this->errorHandler = $errorHandler; } /** - * Create or Update Mertic - * Create or update each metric in the stats collection for the given project + * Create Per Period Metric + * + * Create given metric for each defined period * * @param string $projectId * @param string $metric * @param int $value - * + * @param bool $monthly * @return void - * @throws Exception + * @throws Authorization + * @throws Structure */ - private function createOrUpdateMetric(string $projectId, string $metric, int $value): void + protected function createPerPeriodMetric(string $projectId, string $metric, int $value, bool $monthly = false): void { foreach ($this->periods as $options) { $period = $options['key']; @@ -39,40 +55,66 @@ class UsageDB extends Usage } else { throw new Exception("Period type not found", 500); } + $this->createOrUpdateMetric($projectId, $metric, $period, $time, $value); + } - $id = \md5("{$time}_{$period}_{$metric}"); - $this->database->setNamespace('_' . $projectId); + // Required for billing + if ($monthly) { + $time = strtotime("first day of the month"); + $this->createOrUpdateMetric($projectId, $metric, '1mo', $time, $value); + } + } - try { - $document = $this->database->getDocument('stats', $id); - if ($document->isEmpty()) { - $this->database->createDocument('stats', new Document([ - '$id' => $id, - 'period' => $period, - 'time' => $time, - 'metric' => $metric, - 'value' => $value, - 'type' => 1, - ])); - } else { - $this->database->updateDocument( - 'stats', - $document->getId(), - $document->setAttribute('value', $value) - ); - } - } catch (\Exception $e) { // if projects are deleted this might fail - if (is_callable($this->errorHandler)) { - call_user_func($this->errorHandler, $e, "sync_project_{$projectId}_metric_{$metric}"); - } else { - throw $e; - } + /** + * Create or Update Metric + * + * Create or update each metric in the stats collection for the given project + * + * @param string $projectId + * @param string $metric + * @param string $period + * @param string $time + * @param int $value + * + * @return void + * @throws Authorization + * @throws Structure + */ + protected function createOrUpdateMetric(string $projectId, string $metric, string $period, string $time, int $value): void + { + $id = \md5("{$time}_{$period}_{$metric}"); + $this->database->setNamespace('_' . $projectId); + + try { + $document = $this->database->getDocument('stats', $id); + if ($document->isEmpty()) { + $this->database->createDocument('stats', new Document([ + '$id' => $id, + 'period' => $period, + 'time' => $time, + 'metric' => $metric, + 'value' => $value, + 'type' => 2, // these are cumulative metrics + ])); + } else { + $this->database->updateDocument( + 'stats', + $document->getId(), + $document->setAttribute('value', $value) + ); + } + } catch (\Exception$e) { // if projects are deleted this might fail + if (is_callable($this->errorHandler)) { + call_user_func($this->errorHandler, $e, "sync_project_{$projectId}_metric_{$metric}"); + } else { + throw $e; } } } /** * Foreach Document + * * Call provided callback for each document in the collection * * @param string $projectId @@ -81,8 +123,9 @@ class UsageDB extends Usage * @param callable $callback * * @return void + * @throws Exception */ - private function foreachDocument(string $projectId, string $collection, array $queries, callable $callback): void + protected function foreachDocument(string $projectId, string $collection, array $queries, callable $callback): void { $limit = 50; $results = []; @@ -122,23 +165,28 @@ class UsageDB extends Usage /** * Sum - * Calculate sum of a attribute of documents in collection + * + * Calculate sum of an attribute of documents in collection * * @param string $projectId * @param string $collection * @param string $attribute - * @param string $metric - * + * @param string|null $metric + * @param int $multiplier * @return int * @throws Exception */ - private function sum(string $projectId, string $collection, string $attribute, string $metric): int + private function sum(string $projectId, string $collection, string $attribute, string $metric = null, int $multiplier = 1): int { $this->database->setNamespace('_' . $projectId); try { - $sum = (int) $this->database->sum($collection, $attribute); - $this->createOrUpdateMetric($projectId, $metric, $sum); + $sum = $this->database->sum($collection, $attribute); + $sum = (int) ($sum * $multiplier); + + if (!is_null($metric)) { + $this->createPerPeriodMetric($projectId, $metric, $sum); + } return $sum; } catch (Exception $e) { if (is_callable($this->errorHandler)) { @@ -147,26 +195,30 @@ class UsageDB extends Usage throw $e; } } + return 0; } /** * Count + * * Count number of documents in collection * * @param string $projectId * @param string $collection - * @param string $metric + * @param ?string $metric * * @return int * @throws Exception */ - private function count(string $projectId, string $collection, string $metric): int + private function count(string $projectId, string $collection, ?string $metric = null): int { $this->database->setNamespace('_' . $projectId); try { $count = $this->database->count($collection); - $this->createOrUpdateMetric($projectId, $metric, $count); + if (!is_null($metric)) { + $this->createPerPeriodMetric($projectId, (string) $metric, $count); + } return $count; } catch (Exception $e) { if (is_callable($this->errorHandler)) { @@ -175,113 +227,124 @@ class UsageDB extends Usage throw $e; } } + return 0; } /** * Deployments Total + * * Total sum of storage used by deployments * * @param string $projectId * * @return int + * @throws Exception */ private function deploymentsTotal(string $projectId): int { - return $this->sum($projectId, 'deployments', 'size', 'stroage.deployments.total'); + return $this->sum($projectId, 'deployments', 'size', 'deployments.$all.storage.size'); } /** * Users Stats + * * Metric: users.count * * @param string $projectId * * @return void + * @throws Exception */ private function usersStats(string $projectId): void { - $this->count($projectId, 'users', 'users.count'); + $this->count($projectId, 'users', 'users.$all.count.total'); } /** * Storage Stats - * Metrics: storage.total, storage.files.total, storage.buckets.{bucketId}.files.total, - * storage.buckets.count, storage.files.count, storage.buckets.{bucketId}.files.count + * + * Metrics: buckets.$all.count.total, files.$all.count.total, files.bucketId,count.total, + * files.$all.storage.size, files.bucketId.storage.size, project.$all.storage.size * * @param string $projectId * * @return void + * @throws Authorization + * @throws Structure */ private function storageStats(string $projectId): void { - $deploymentsTotal = $this->deploymentsTotal($projectId); - $projectFilesTotal = 0; $projectFilesCount = 0; - $metric = 'storage.buckets.count'; + $metric = 'buckets.$all.count.total'; $this->count($projectId, 'buckets', $metric); $this->foreachDocument($projectId, 'buckets', [], function ($bucket) use (&$projectFilesCount, &$projectFilesTotal, $projectId,) { - $metric = "storage.buckets.{$bucket->getId()}.files.count"; - + $metric = "files.{$bucket->getId()}.count.total"; $count = $this->count($projectId, 'bucket_' . $bucket->getInternalId(), $metric); $projectFilesCount += $count; - $metric = "storage.buckets.{$bucket->getId()}.files.total"; + $metric = "files.{$bucket->getId()}.storage.size"; $sum = $this->sum($projectId, 'bucket_' . $bucket->getInternalId(), 'sizeOriginal', $metric); $projectFilesTotal += $sum; }); - $this->createOrUpdateMetric($projectId, 'storage.files.count', $projectFilesCount); - $this->createOrUpdateMetric($projectId, 'storage.files.total', $projectFilesTotal); + $this->createPerPeriodMetric($projectId, 'files.$all.count.total', $projectFilesCount); + $this->createPerPeriodMetric($projectId, 'files.$all.storage.size', $projectFilesTotal); - $this->createOrUpdateMetric($projectId, 'storage.total', $projectFilesTotal + $deploymentsTotal); + $deploymentsTotal = $this->deploymentsTotal($projectId); + $this->createPerPeriodMetric($projectId, 'project.$all.storage.size', $projectFilesTotal + $deploymentsTotal); } /** * Database Stats + * * Collect all database stats - * Metrics: database.collections.count, database.collections.{collectionId}.documents.count, - * database.documents.count + * Metrics: databases.$all.count.total, collections.$all.count.total, collections.databaseId.count.total, + * documents.$all.count.all, documents.databaseId.count.total, documents.databaseId/collectionId.count.total * * @param string $projectId * * @return void + * @throws Authorization + * @throws Structure */ private function databaseStats(string $projectId): void { $projectDocumentsCount = 0; $projectCollectionsCount = 0; - $this->count($projectId, 'databases', 'databases.count'); + $this->count($projectId, 'databases', 'databases.$all.count.total'); $this->foreachDocument($projectId, 'databases', [], function ($database) use (&$projectDocumentsCount, &$projectCollectionsCount, $projectId) { - $metric = "databases.{$database->getId()}.collections.count"; + $metric = "collections.{$database->getId()}.count.total"; $count = $this->count($projectId, 'database_' . $database->getInternalId(), $metric); $projectCollectionsCount += $count; $databaseDocumentsCount = 0; $this->foreachDocument($projectId, 'database_' . $database->getInternalId(), [], function ($collection) use (&$projectDocumentsCount, &$databaseDocumentsCount, $projectId, $database) { - $metric = "databases.{$database->getId()}.collections.{$collection->getId()}.documents.count"; + $metric = "documents.{$database->getId()}/{$collection->getId()}.count.total"; $count = $this->count($projectId, 'database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $metric); $projectDocumentsCount += $count; $databaseDocumentsCount += $count; }); - $this->createOrUpdateMetric($projectId, "databases.{$database->getId()}.documents.count", $databaseDocumentsCount); + $this->createPerPeriodMetric($projectId, "documents.{$database->getId()}.count.total", $databaseDocumentsCount); }); - $this->createOrUpdateMetric($projectId, 'databases.collections.count', $projectCollectionsCount); - $this->createOrUpdateMetric($projectId, 'databases.documents.count', $projectDocumentsCount); + $this->createPerPeriodMetric($projectId, 'collections.$all.count.total', $projectCollectionsCount); + $this->createPerPeriodMetric($projectId, 'documents.$all.count.total', $projectDocumentsCount); } /** * Collect Stats + * * Collect all database related stats * * @return void + * @throws Exception */ public function collect(): void { diff --git a/src/Appwrite/Usage/Calculators/TimeSeries.php b/src/Appwrite/Usage/Calculators/TimeSeries.php new file mode 100644 index 0000000000..0a15923b2d --- /dev/null +++ b/src/Appwrite/Usage/Calculators/TimeSeries.php @@ -0,0 +1,432 @@ + [ + 'table' => 'appwrite_usage_project_{scope}_network_requests', + ], + 'project.$all.network.bandwidth' => [ + 'table' => 'appwrite_usage_project_{scope}_network_bandwidth', + ], + 'project.$all.network.inbound' => [ + 'table' => 'appwrite_usage_project_{scope}_network_inbound', + ], + 'project.$all.network.outbound' => [ + 'table' => 'appwrite_usage_project_{scope}_network_outbound', + ], + /* Users service metrics */ + 'users.$all.requests.create' => [ + 'table' => 'appwrite_usage_users_{scope}_requests_create', + ], + 'users.$all.requests.read' => [ + 'table' => 'appwrite_usage_users_{scope}_requests_read', + ], + 'users.$all.requests.update' => [ + 'table' => 'appwrite_usage_users_{scope}_requests_update', + ], + 'users.$all.requests.delete' => [ + 'table' => 'appwrite_usage_users_{scope}_requests_delete', + ], + + 'databases.$all.requests.create' => [ + 'table' => 'appwrite_usage_databases_{scope}_requests_create', + ], + 'databases.$all.requests.read' => [ + 'table' => 'appwrite_usage_databases_{scope}_requests_read', + ], + 'databases.$all.requests.update' => [ + 'table' => 'appwrite_usage_databases_{scope}_requests_update', + ], + 'databases.$all.requests.delete' => [ + 'table' => 'appwrite_usage_databases_{scope}_requests_delete', + ], + + 'collections.$all.requests.create' => [ + 'table' => 'appwrite_usage_collections_{scope}_requests_create', + ], + 'collections.$all.requests.read' => [ + 'table' => 'appwrite_usage_collections_{scope}_requests_read', + ], + 'collections.$all.requests.update' => [ + 'table' => 'appwrite_usage_collections_{scope}_requests_update', + ], + 'collections.$all.requests.delete' => [ + 'table' => 'appwrite_usage_collections_{scope}_requests_delete', + ], + + 'documents.$all.requests.create' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_create', + ], + 'documents.$all.requests.read' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_read', + ], + 'documents.$all.requests.update' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_update', + ], + 'documents.$all.requests.delete' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_delete', + ], + + 'collections.databaseId.requests.create' => [ + 'table' => 'appwrite_usage_collections_{scope}_requests_create', + 'groupBy' => ['databaseId'], + ], + 'collections.databaseId.requests.read' => [ + 'table' => 'appwrite_usage_collections_{scope}_requests_read', + 'groupBy' => ['databaseId'], + ], + 'collections.databaseId.requests.update' => [ + 'table' => 'appwrite_usage_collections_{scope}_requests_update', + 'groupBy' => ['databaseId'], + ], + 'collections.databaseId.requests.delete' => [ + 'table' => 'appwrite_usage_collections_{scope}_requests_delete', + 'groupBy' => ['databaseId'], + ], + + 'documents.databaseId.requests.create' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_create', + 'groupBy' => ['databaseId'], + ], + 'documents.databaseId.requests.read' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_read', + 'groupBy' => ['databaseId'], + ], + 'documents.databaseId.requests.update' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_update', + 'groupBy' => ['databaseId'], + ], + 'documents.databaseId.requests.delete' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_delete', + 'groupBy' => ['databaseId'], + ], + + 'documents.databaseId/collectionId.requests.create' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_create', + 'groupBy' => ['databaseId', 'collectionId'], + ], + 'documents.databaseId/collectionId.requests.read' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_read', + 'groupBy' => ['databaseId', 'collectionId'], + ], + 'documents.databaseId/collectionId.requests.update' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_update', + 'groupBy' => ['databaseId', 'collectionId'], + ], + 'documents.databaseId/collectionId.requests.delete' => [ + 'table' => 'appwrite_usage_documents_{scope}_requests_delete', + 'groupBy' => ['databaseId', 'collectionId'], + ], + + 'buckets.$all.requests.create' => [ + 'table' => 'appwrite_usage_buckets_{scope}_requests_create', + ], + 'buckets.$all.requests.read' => [ + 'table' => 'appwrite_usage_buckets_{scope}_requests_read', + ], + 'buckets.$all.requests.update' => [ + 'table' => 'appwrite_usage_buckets_{scope}_requests_update', + ], + 'buckets.$all.requests.delete' => [ + 'table' => 'appwrite_usage_buckets_{scope}_requests_delete', + ], + + 'files.$all.requests.create' => [ + 'table' => 'appwrite_usage_files_{scope}_requests_create', + ], + 'files.$all.requests.read' => [ + 'table' => 'appwrite_usage_files_{scope}_requests_read', + ], + 'files.$all.requests.update' => [ + 'table' => 'appwrite_usage_files_{scope}_requests_update', + ], + 'files.$all.requests.delete' => [ + 'table' => 'appwrite_usage_files_{scope}_requests_delete', + ], + + 'files.bucketId.requests.create' => [ + 'table' => 'appwrite_usage_files_{scope}_requests_create', + 'groupBy' => ['bucketId'], + ], + 'files.bucketId.requests.read' => [ + 'table' => 'appwrite_usage_files_{scope}_requests_read', + 'groupBy' => ['bucketId'], + ], + 'files.bucketId.requests.update' => [ + 'table' => 'appwrite_usage_files_{scope}_requests_update', + 'groupBy' => ['bucketId'], + ], + 'files.bucketId.requests.delete' => [ + 'table' => 'appwrite_usage_files_{scope}_requests_delete', + 'groupBy' => ['bucketId'], + ], + + 'sessions.$all.requests.create' => [ + 'table' => 'appwrite_usage_sessions__{scope}_requests_create', + ], + 'sessions.provider.requests.create' => [ + 'table' => 'appwrite_usage_sessions_{scope}_requests_create', + 'groupBy' => ['provider'], + ], + 'sessions.$all.requests.delete' => [ + 'table' => 'appwrite_usage_sessions_{scope}_requests_delete', + ], + 'executions.$all.compute.total' => [ + 'table' => 'appwrite_usage_executions_{scope}_compute', + ], + 'builds.$all.compute.time' => [ + 'table' => 'appwrite_usage_executions_{scope}_compute_time', + ], + 'executions.$all.compute.time' => [ + 'table' => 'appwrite_usage_executions_{scope}_compute_time', + ], + 'builds.$all.compute.total' => [ + 'table' => 'appwrite_usage_builds_{scope}_compute', + ], + 'executions.$all.compute.failure' => [ + 'table' => 'appwrite_usage_executions_{scope}_compute', + 'filters' => [ + 'functionStatus' => 'failed', + ], + ], + 'builds.$all.compute.failure' => [ + 'table' => 'appwrite_usage_builds_{scope}_compute', + 'filters' => [ + 'functionStatus' => 'failed', + ], + ], + 'executions.$all.compute.success' => [ + 'table' => 'appwrite_usage_executions_{scope}_compute', + 'filters' => [ + 'functionStatus' => 'success', + ], + ], + 'builds.$all.compute.success' => [ + 'table' => 'appwrite_usage_builds_{scope}_compute', + 'filters' => [ + 'functionStatus' => 'success', + ], + ], + 'executions.functionId.compute.total' => [ + 'table' => 'appwrite_usage_executions_{scope}_compute', + 'groupBy' => ['functionId'], + ], + 'builds.functionId.compute.total' => [ + 'table' => 'appwrite_usage_builds_{scope}_compute', + 'groupBy' => ['functionId'], + ], + 'executions.functionId.compute.time' => [ + 'table' => 'appwrite_usage_executions_{scope}_compute_time', + 'groupBy' => ['functionId'], + ], + 'builds.functionId.compute.time' => [ + 'table' => 'appwrite_usage_builds_{scope}_compute_time', + 'groupBy' => ['functionId'], + ], + 'executions.functionId.compute.failure' => [ + 'table' => 'appwrite_usage_executions_{scope}_compute', + 'groupBy' => ['functionId'], + 'filters' => [ + 'functionStatus' => 'failed', + ], + ], + 'builds.functionId.compute.failure' => [ + 'table' => 'appwrite_usage_builds_{scope}_compute', + 'groupBy' => ['functionId'], + 'filters' => [ + 'functionBuildStatus' => 'failed', + ], + ], + 'executions.functionId.compute.success' => [ + 'table' => 'appwrite_usage_executions_{scope}_compute', + 'groupBy' => ['functionId'], + 'filters' => [ + 'functionStatus' => 'success', + ], + ], + 'builds.functionId.compute.success' => [ + 'table' => 'appwrite_usage_builds_{scope}_compute', + 'groupBy' => ['functionId'], + 'filters' => [ + 'functionBuildStatus' => 'success', + ], + ], + + 'project.$all.compute.time' => [ // Built time + execution time + 'table' => 'appwrite_usage_project_{scope}_compute_time', + 'groupBy' => ['functionId'], + ], + ]; + + protected array $period = [ + 'key' => '30m', + 'startTime' => '-24 hours', + ]; + + public function __construct(Database $database, InfluxDatabase $influxDB, callable $errorHandler = null) + { + $this->database = $database; + $this->influxDB = $influxDB; + $this->errorHandler = $errorHandler; + } + + /** + * Create or Update Mertic + * Create or update each metric in the stats collection for the given project + * + * @param string $projectId + * @param int $time + * @param string $period + * @param string $metric + * @param int $value + * @param int $type + * + * @return void + */ + private function createOrUpdateMetric(string $projectId, int $time, string $period, string $metric, int $value, int $type): void + { + $id = \md5("{$time}_{$period}_{$metric}"); + $this->database->setNamespace('_console'); + $project = $this->database->getDocument('projects', $projectId); + $this->database->setNamespace('_' . $project->getInternalId()); + + try { + $document = $this->database->getDocument('stats', $id); + if ($document->isEmpty()) { + $this->database->createDocument('stats', new Document([ + '$id' => $id, + 'period' => $period, + 'time' => $time, + 'metric' => $metric, + 'value' => $value, + 'type' => $type, + ])); + } else { + $this->database->updateDocument( + 'stats', + $document->getId(), + $document->setAttribute('value', $value) + ); + } + $this->latestTime[$metric][$period] = $time; + } catch (\Exception $e) { // if projects are deleted this might fail + if (is_callable($this->errorHandler)) { + call_user_func($this->errorHandler, $e, "sync_project_{$projectId}_metric_{$metric}"); + } else { + throw $e; + } + } + } + + /** + * Sync From InfluxDB + * Sync stats from influxDB to stats collection in the Appwrite database + * + * @param string $metric + * @param array $options + * @param array $period + * + * @return void + */ + private function syncFromInfluxDB(string $metric, array $options, array $period): void + { + $start = DateTime::createFromFormat('U', \strtotime($period['startTime']))->format(DateTime::RFC3339); + if (!empty($this->latestTime[$metric][$period['key']])) { + $start = DateTime::createFromFormat('U', $this->latestTime[$metric][$period['key']])->format(DateTime::RFC3339); + } + $end = DateTime::createFromFormat('U', \strtotime('now'))->format(DateTime::RFC3339); + + $table = $options['table']; //Which influxdb table to query for this metric + $groupBy = empty($options['groupBy']) ? '' : ', ' . implode(', ', array_map(fn($groupBy) => '"' . $groupBy . '" ', $options['groupBy'])); //Some sub level metrics may be grouped by other tags like collectionId, bucketId, etc + + $filters = $options['filters'] ?? []; // Some metrics might have additional filters, like function's status + if (!empty($filters)) { + $filters = ' AND ' . implode(' AND ', array_map(fn ($filter, $value) => "\"{$filter}\"='{$value}'", array_keys($filters), array_values($filters))); + } else { + $filters = ''; + } + + $query = "SELECT sum(value) AS \"value\" "; + $query .= "FROM \"{$table}\" "; + $query .= "WHERE \"time\" > '{$start}' "; + $query .= "AND \"time\" < '{$end}' "; + $query .= "AND \"metric_type\"='counter' {$filters} "; + $query .= "GROUP BY time({$period['key']}), \"projectId\" {$groupBy} "; + $query .= "FILL(null)"; + + try { + $result = $this->influxDB->query($query); + $points = $result->getPoints(); + foreach ($points as $point) { + $projectId = $point['projectId']; + + if (!empty($projectId) && $projectId !== 'console') { + $metricUpdated = $metric; + if (!empty($groupBy)) { + foreach ($options['groupBy'] as $groupBy) { + $groupedBy = $point[$groupBy] ?? ''; + if (empty($groupedBy)) { + continue; + } + $metricUpdated = str_replace($groupBy, $groupedBy, $metricUpdated); + } + } + + $time = \strtotime($point['time']); + $value = (!empty($point['value'])) ? $point['value'] : 0; + + $this->createOrUpdateMetric( + $projectId, + $time, + $period['key'], + $metricUpdated, + $value, + 0 + ); + } + } + } catch (\Exception $e) { // if projects are deleted this might fail + if (is_callable($this->errorHandler)) { + call_user_func($this->errorHandler, $e, "sync_metric_{$metric}_influxdb"); + } else { + throw $e; + } + } + } + + /** + * Collect Stats + * Collect all the stats from Influd DB to Database + * + * @return void + */ + public function collect(): void + { + foreach ($this->metrics as $metric => $options) { //for each metrics + try { + $this->syncFromInfluxDB($metric, $options, $this->period); + } catch (\Exception $e) { + if (is_callable($this->errorHandler)) { + call_user_func($this->errorHandler, $e); + } else { + throw $e; + } + } + } + } +} diff --git a/src/Appwrite/Usage/Stats.php b/src/Appwrite/Usage/Stats.php new file mode 100644 index 0000000000..00298238f6 --- /dev/null +++ b/src/Appwrite/Usage/Stats.php @@ -0,0 +1,204 @@ +statsd = $statsd; + } + + /** + * @param string $key + * @param mixed $value + * + * @return $this + */ + public function setParam(string $key, $value): self + { + $this->params[$key] = $value; + + return $this; + } + + /** + * @param string $key + * + * @return mixed|null + */ + public function getParam(string $key) + { + return (isset($this->params[$key])) ? $this->params[$key] : null; + } + + /** + * @param string $namespace + * + * @return $this + */ + public function setNamespace(string $namespace): self + { + $this->namespace = $namespace; + + return $this; + } + + /** + * @return string + */ + public function getNamespace() + { + return $this->namespace; + } + + /** + * Submit data to StatsD. + */ + public function submit(): void + { + $projectId = $this->params['projectId'] ?? ''; + $tags = ",projectId={$projectId},version=" . App::getEnv('_APP_VERSION', 'UNKNOWN'); + + // the global namespace is prepended to every key (optional) + $this->statsd->setNamespace($this->namespace); + + $httpRequest = $this->params['project.{scope}.network.requests'] ?? 0; + $httpMethod = $this->params['httpMethod'] ?? ''; + if ($httpRequest >= 1) { + $this->statsd->increment('project.{scope}.network.requests' . $tags . ',method=' . \strtolower($httpMethod)); + } + + $inbound = $this->params['networkRequestSize'] ?? 0; + $outbound = $this->params['networkResponseSize'] ?? 0; + $this->statsd->count('project.{scope}.network.inbound' . $tags, $inbound); + $this->statsd->count('project.{scope}.network.outbound' . $tags, $outbound); + $this->statsd->count('project.{scope}.network.bandwidth' . $tags, $inbound + $outbound); + + $usersMetrics = [ + 'users.{scope}.requests.create', + 'users.{scope}.requests.read', + 'users.{scope}.requests.update', + 'users.{scope}.requests.delete', + ]; + + foreach ($usersMetrics as $metric) { + $value = $this->params[$metric] ?? 0; + if ($value >= 1) { + $this->statsd->increment($metric . $tags); + } + } + + $dbMetrics = [ + 'databases.{scope}.requests.create', + 'databases.{scope}.requests.read', + 'databases.{scope}.requests.update', + 'databases.{scope}.requests.delete', + 'collections.{scope}.requests.create', + 'collections.{scope}.requests.read', + 'collections.{scope}.requests.update', + 'collections.{scope}.requests.delete', + 'documents.{scope}.requests.create', + 'documents.{scope}.requests.read', + 'documents.{scope}.requests.update', + 'documents.{scope}.requests.delete', + ]; + + foreach ($dbMetrics as $metric) { + $value = $this->params[$metric] ?? 0; + if ($value >= 1) { + $dbTags = $tags . ",collectionId=" . ($this->params['collectionId'] ?? '') . ",databaseId=" . ($this->params['databaseId'] ?? ''); + $this->statsd->increment($metric . $dbTags); + } + } + + $storageMertics = [ + 'buckets.{scope}.requests.create', + 'buckets.{scope}.requests.read', + 'buckets.{scope}.requests.update', + 'buckets.{scope}.requests.delete', + 'files.{scope}.requests.create', + 'files.{scope}.requests.read', + 'files.{scope}.requests.update', + 'files.{scope}.requests.delete', + ]; + + foreach ($storageMertics as $metric) { + $value = $this->params[$metric] ?? 0; + if ($value >= 1) { + $storageTags = $tags . ",bucketId=" . ($this->params['bucketId'] ?? ''); + $this->statsd->increment($metric . $storageTags); + } + } + + $sessionsMetrics = [ + 'sessions.{scope}.requests.create', + 'sessions.{scope}.requests.update', + 'sessions.{scope}.requests.delete', + ]; + + foreach ($sessionsMetrics as $metric) { + $value = $this->params[$metric] ?? 0; + if ($value >= 1) { + $sessionTags = $tags . ",provider=" . ($this->params['provider'] ?? ''); + $this->statsd->count($metric . $sessionTags, $value); + } + } + + $functionId = $this->params['functionId'] ?? ''; + $functionExecution = $this->params['executions.{scope}.compute'] ?? 0; + $functionExecutionTime = ($this->params['executionTime'] ?? 0) * 1000; // ms + $functionExecutionStatus = $this->params['executionStatus'] ?? ''; + + $functionBuild = $this->params['builds.{scope}.compute'] ?? 0; + $functionBuildTime = ($this->params['buildTime'] ?? 0) * 1000; // ms + $functionBuildStatus = $this->params['buildStatus'] ?? ''; + $functionCompute = $functionExecutionTime + $functionBuildTime; + + if ($functionExecution >= 1) { + $this->statsd->increment('executions.{scope}.compute' . $tags . ',functionId=' . $functionId . ',functionStatus=' . $functionExecutionStatus); + if ($functionExecutionTime > 0) { + $this->statsd->count('executions.{scope}.compute.time' . $tags . ',functionId=' . $functionId, $functionExecutionTime); + } + } + if ($functionBuild >= 1) { + $this->statsd->increment('builds.{scope}.compute' . $tags . ',functionId=' . $functionId . ',functionBuildStatus=' . $functionBuildStatus); + $this->statsd->count('builds.{scope}.compute.time' . $tags . ',functionId=' . $functionId, $functionBuildTime); + } + if ($functionBuild + $functionExecution >= 1) { + $this->statsd->count('project.{scope}.compute.time' . $tags . ',functionId=' . $functionId, $functionCompute); + } + + $this->reset(); + } + + public function reset(): self + { + $this->params = []; + $this->namespace = 'appwrite.usage'; + + return $this; + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 0113d313f4..b234eca244 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -75,6 +75,7 @@ use Appwrite\Utopia\Response\Model\UsageBuckets; use Appwrite\Utopia\Response\Model\UsageCollection; use Appwrite\Utopia\Response\Model\UsageDatabase; use Appwrite\Utopia\Response\Model\UsageDatabases; +use Appwrite\Utopia\Response\Model\UsageFunction; use Appwrite\Utopia\Response\Model\UsageFunctions; use Appwrite\Utopia\Response\Model\UsageProject; use Appwrite\Utopia\Response\Model\UsageStorage; @@ -102,6 +103,7 @@ class Response extends SwooleResponse public const MODEL_USAGE_BUCKETS = 'usageBuckets'; public const MODEL_USAGE_STORAGE = 'usageStorage'; public const MODEL_USAGE_FUNCTIONS = 'usageFunctions'; + public const MODEL_USAGE_FUNCTION = 'usageFunction'; public const MODEL_USAGE_PROJECT = 'usageProject'; // Database @@ -325,6 +327,7 @@ class Response extends SwooleResponse ->setModel(new UsageStorage()) ->setModel(new UsageBuckets()) ->setModel(new UsageFunctions()) + ->setModel(new UsageFunction()) ->setModel(new UsageProject()) // Verification // Recovery diff --git a/src/Appwrite/Utopia/Response/Model/UsageFunction.php b/src/Appwrite/Utopia/Response/Model/UsageFunction.php new file mode 100644 index 0000000000..676e6c5578 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/UsageFunction.php @@ -0,0 +1,97 @@ +addRule('range', [ + 'type' => self::TYPE_STRING, + 'description' => 'The time range of the usage stats.', + 'default' => '', + 'example' => '30d', + ]) + ->addRule('executionsTotal', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for number of function executions.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('executionsFailure', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for function execution failures.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('executionsSuccess', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for function execution successes.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('executionsTime', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for function execution duration.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('buildsTotal', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for number of function builds.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('buildsFailure', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for function build failures.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('buildsSuccess', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for function build successes.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('buildsTime', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for function build duration.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'UsageFunction'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_USAGE_FUNCTION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/UsageFunctions.php b/src/Appwrite/Utopia/Response/Model/UsageFunctions.php index 48ba65fa21..9fb9fbabcc 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageFunctions.php +++ b/src/Appwrite/Utopia/Response/Model/UsageFunctions.php @@ -16,27 +16,62 @@ class UsageFunctions extends Model 'default' => '', 'example' => '30d', ]) - ->addRule('functionsExecutions', [ + ->addRule('executionsTotal', [ 'type' => Response::MODEL_METRIC_LIST, - 'description' => 'Aggregated stats for function executions.', + 'description' => 'Aggregated stats for number of function executions.', 'default' => [], 'example' => new \stdClass(), 'array' => true ]) - ->addRule('functionsFailures', [ + ->addRule('executionsFailure', [ 'type' => Response::MODEL_METRIC_LIST, 'description' => 'Aggregated stats for function execution failures.', 'default' => [], 'example' => new \stdClass(), 'array' => true ]) - ->addRule('functionsCompute', [ + ->addRule('executionsSuccess', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for function execution successes.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('executionsTime', [ 'type' => Response::MODEL_METRIC_LIST, 'description' => 'Aggregated stats for function execution duration.', 'default' => [], 'example' => new \stdClass(), 'array' => true ]) + ->addRule('buildsTotal', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for number of function builds.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('buildsFailure', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for function build failures.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('buildsSuccess', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for function build successes.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) + ->addRule('buildsTime', [ + 'type' => Response::MODEL_METRIC_LIST, + 'description' => 'Aggregated stats for function build duration.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true + ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/UsageProject.php b/src/Appwrite/Utopia/Response/Model/UsageProject.php index 2b431181ce..f630035ac1 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageProject.php +++ b/src/Appwrite/Utopia/Response/Model/UsageProject.php @@ -30,7 +30,7 @@ class UsageProject extends Model 'example' => new \stdClass(), 'array' => true ]) - ->addRule('functions', [ + ->addRule('executions', [ 'type' => Response::MODEL_METRIC_LIST, 'description' => 'Aggregated stats for function executions.', 'default' => [], diff --git a/src/Appwrite/Utopia/Response/Model/UsageStorage.php b/src/Appwrite/Utopia/Response/Model/UsageStorage.php index 7d0d5ed0fa..bdacfe9ea4 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageStorage.php +++ b/src/Appwrite/Utopia/Response/Model/UsageStorage.php @@ -16,16 +16,9 @@ class UsageStorage extends Model 'default' => '', 'example' => '30d', ]) - ->addRule('filesStorage', [ + ->addRule('storage', [ 'type' => Response::MODEL_METRIC_LIST, - 'description' => 'Aggregated stats for the occupied storage size by files (in bytes).', - 'default' => [], - 'example' => new \stdClass(), - 'array' => true - ]) - ->addRule('tagsStorage', [ - 'type' => Response::MODEL_METRIC_LIST, - 'description' => 'Aggregated stats for the occupied storage size by tags (in bytes).', + 'description' => 'Aggregated stats for the occupied storage size (in bytes).', 'default' => [], 'example' => new \stdClass(), 'array' => true diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php new file mode 100644 index 0000000000..c320fba76c --- /dev/null +++ b/tests/e2e/General/UsageTest.php @@ -0,0 +1,570 @@ +getProject(true); + $projectId = $project['$id']; + $headers['x-appwrite-project'] = $project['$id']; + $headers['x-appwrite-key'] = $project['apiKey']; + $headers['content-type'] = 'application/json'; + + $usersCount = 0; + $requestsCount = 0; + for ($i = 0; $i < 10; $i++) { + $email = uniqid() . 'user@usage.test'; + $password = 'password'; + $name = uniqid() . 'User'; + $res = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => 'unique()', + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + $this->assertEquals($email, $res['body']['email']); + $this->assertNotEmpty($res['body']['$id']); + $usersCount++; + $requestsCount++; + + if ($i < 5) { + $userId = $res['body']['$id']; + $res = $this->client->call(Client::METHOD_GET, '/users/' . $userId, $headers); + $this->assertEquals($userId, $res['body']['$id']); + $res = $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, $headers); + $this->assertEmpty($res['body']); + $requestsCount += 2; + $usersCount--; + } + } + + sleep(35); + + // console request + $cheaders = [ + 'origin' => 'http://localhost', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]; + + $res = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/usage?range=30d', $cheaders); + $res = $res['body']; + + $this->assertEquals(8, count($res)); + $this->assertEquals(30, count($res['requests'])); + $this->assertEquals(30, count($res['users'])); + $this->assertEquals($usersCount, $res['users'][array_key_last($res['users'])]['value']); + $this->assertEquals($requestsCount, $res['requests'][array_key_last($res['requests'])]['value']); + + $res = $this->client->call(Client::METHOD_GET, '/users/usage?range=30d', array_merge($cheaders, [ + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin' + ])); + $res = $res['body']; + $this->assertEquals(10, $res['usersCreate'][array_key_last($res['usersCreate'])]['value']); + $this->assertEquals(5, $res['usersRead'][array_key_last($res['usersRead'])]['value']); + $this->assertEquals(5, $res['usersDelete'][array_key_last($res['usersDelete'])]['value']); + + return ['projectId' => $projectId, 'headers' => $headers, 'requestsCount' => $requestsCount]; + } + + /** @depends testUsersStats */ + public function testStorageStats(array $data): array + { + $projectId = $data['projectId']; + $headers = $data['headers']; + + $bucketId = ''; + $bucketsCount = 0; + $requestsCount = $data['requestsCount']; + $storageTotal = 0; + $bucketsCreate = 0; + $bucketsDelete = 0; + $bucketsRead = 0; + $filesCount = 0; + $filesRead = 0; + $filesCreate = 0; + $filesDelete = 0; + + for ($i = 0; $i < 10; $i++) { + $name = uniqid() . ' bucket'; + $res = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ + 'bucketId' => 'unique()', + 'name' => $name, + 'permission' => 'bucket' + ]); + $this->assertEquals($name, $res['body']['name']); + $this->assertNotEmpty($res['body']['$id']); + $bucketId = $res['body']['$id']; + + $bucketsCreate++; + $bucketsCount++; + $requestsCount++; + + if ($i < 5) { + $res = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId, $headers); + $this->assertEquals($bucketId, $res['body']['$id']); + $bucketsRead++; + + $res = $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, $headers); + $this->assertEmpty($res['body']); + $bucketsDelete++; + + $requestsCount += 2; + $bucketsCount--; + } + } + + // upload some files + $files = [ + [ + 'path' => realpath(__DIR__ . '/../../resources/logo.png'), + 'name' => 'logo.png', + ], + [ + 'path' => realpath(__DIR__ . '/../../resources/file.png'), + 'name' => 'file.png', + ], + [ + 'path' => realpath(__DIR__ . '/../../resources/disk-a/kitten-3.gif'), + 'name' => 'kitten-3.gif', + ], + [ + 'path' => realpath(__DIR__ . '/../../resources/disk-a/kitten-1.jpg'), + 'name' => 'kitten-1.jpg', + ], + ]; + + for ($i = 0; $i < 10; $i++) { + $file = $files[$i % count($files)]; + $res = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge($headers, ['content-type' => 'multipart/form-data']), [ + 'fileId' => 'unique()', + 'file' => new CURLFile($file['path'], '', $file['name']), + ]); + $this->assertNotEmpty($res['body']['$id']); + + $fileSize = $res['body']['sizeOriginal']; + $storageTotal += $fileSize; + $filesCount++; + $filesCreate++; + $requestsCount++; + + $fileId = $res['body']['$id']; + if ($i < 5) { + $res = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId, $headers); + $this->assertEquals($fileId, $res['body']['$id']); + $filesRead++; + + $res = $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, $headers); + $this->assertEmpty($res['body']); + $filesDelete++; + $requestsCount += 2; + $filesCount--; + $storageTotal -= $fileSize; + } + } + + sleep(35); + + // console request + $headers = [ + 'origin' => 'http://localhost', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]; + + $res = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/usage?range=30d', $headers); + $res = $res['body']; + + $this->assertEquals(8, count($res)); + $this->assertEquals(30, count($res['requests'])); + $this->assertEquals(30, count($res['storage'])); + $this->assertEquals($requestsCount, $res['requests'][array_key_last($res['requests'])]['value']); + $this->assertEquals($storageTotal, $res['storage'][array_key_last($res['storage'])]['value']); + + $res = $this->client->call(Client::METHOD_GET, '/storage/usage?range=30d', array_merge($headers, [ + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin' + ])); + $res = $res['body']; + $this->assertEquals($storageTotal, $res['storage'][array_key_last($res['storage'])]['value']); + $this->assertEquals($bucketsCount, $res['bucketsCount'][array_key_last($res['bucketsCount'])]['value']); + $this->assertEquals($bucketsRead, $res['bucketsRead'][array_key_last($res['bucketsRead'])]['value']); + $this->assertEquals($bucketsCreate, $res['bucketsCreate'][array_key_last($res['bucketsCreate'])]['value']); + $this->assertEquals($bucketsDelete, $res['bucketsDelete'][array_key_last($res['bucketsDelete'])]['value']); + $this->assertEquals($filesCount, $res['filesCount'][array_key_last($res['filesCount'])]['value']); + $this->assertEquals($filesRead, $res['filesRead'][array_key_last($res['filesRead'])]['value']); + $this->assertEquals($filesCreate, $res['filesCreate'][array_key_last($res['filesCreate'])]['value']); + $this->assertEquals($filesDelete, $res['filesDelete'][array_key_last($res['filesDelete'])]['value']); + + $res = $this->client->call(Client::METHOD_GET, '/storage/' . $bucketId . '/usage?range=30d', array_merge($headers, [ + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin' + ])); + $res = $res['body']; + $this->assertEquals($storageTotal, $res['filesStorage'][array_key_last($res['filesStorage'])]['value']); + $this->assertEquals($filesCount, $res['filesCount'][array_key_last($res['filesCount'])]['value']); + $this->assertEquals($filesRead, $res['filesRead'][array_key_last($res['filesRead'])]['value']); + $this->assertEquals($filesCreate, $res['filesCreate'][array_key_last($res['filesCreate'])]['value']); + $this->assertEquals($filesDelete, $res['filesDelete'][array_key_last($res['filesDelete'])]['value']); + + $data['requestsCount'] = $requestsCount; + return $data; + } + + /** @depends testStorageStats */ + public function testDatabaseStats(array $data): array + { + $headers = $data['headers']; + $projectId = $data['projectId']; + + $databaseId = ''; + $collectionId = ''; + + $requestsCount = $data['requestsCount']; + $databasesCount = 0; + $databasesCreate = 0; + $databasesRead = 0; + $databasesDelete = 0; + + $collectionsCount = 0; + $collectionsCreate = 0; + $collectionsRead = 0; + $collectionsUpdate = 0; + $collectionsDelete = 0; + + $documentsCount = 0; + $documentsCreate = 0; + $documentsRead = 0; + $documentsDelete = 0; + + for ($i = 0; $i < 10; $i++) { + $name = uniqid() . ' database'; + $res = $this->client->call(Client::METHOD_POST, '/databases', $headers, [ + 'databaseId' => 'unique()', + 'name' => $name, + ]); + $this->assertEquals($name, $res['body']['name']); + $this->assertNotEmpty($res['body']['$id']); + $databaseId = $res['body']['$id']; + + $requestsCount++; + $databasesCount++; + $databasesCreate++; + + if ($i < 5) { + $res = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId, $headers); + $this->assertEquals($databaseId, $res['body']['$id']); + $databasesRead++; + + $res = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $headers); + $this->assertEmpty($res['body']); + $databasesDelete++; + + $databasesCount--; + $requestsCount += 2; + } + } + + for ($i = 0; $i < 10; $i++) { + $name = uniqid() . ' collection'; + $res = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', $headers, [ + 'collectionId' => 'unique()', + 'name' => $name, + 'permission' => 'collection', + 'read' => ['role:all'], + 'write' => ['role:all'] + ]); + $this->assertEquals($name, $res['body']['name']); + $this->assertNotEmpty($res['body']['$id']); + $collectionId = $res['body']['$id']; + + $requestsCount++; + $collectionsCount++; + $collectionsCreate++; + + if ($i < 5) { + $res = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId, $headers); + $this->assertEquals($collectionId, $res['body']['$id']); + $collectionsRead++; + + $res = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $collectionId, $headers); + $this->assertEmpty($res['body']); + $collectionsDelete++; + + $collectionsCount--; + $requestsCount += 2; + } + } + + $res = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes' . '/string', $headers, [ + 'key' => 'name', + 'size' => 255, + 'required' => true, + ]); + $this->assertEquals('name', $res['body']['key']); + $collectionsUpdate++; + $requestsCount++; + sleep(10); + + for ($i = 0; $i < 10; $i++) { + $name = uniqid() . ' collection'; + $res = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers, [ + 'documentId' => 'unique()', + 'data' => ['name' => $name] + ]); + $this->assertEquals($name, $res['body']['name']); + $this->assertNotEmpty($res['body']['$id']); + $documentId = $res['body']['$id']; + + $requestsCount++; + $documentsCount++; + $documentsCreate++; + + if ($i < 5) { + $res = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId, $headers); + $this->assertEquals($documentId, $res['body']['$id']); + $documentsRead++; + + $res = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId, $headers); + $this->assertEmpty($res['body']); + $documentsDelete++; + + $documentsCount--; + $requestsCount += 2; + } + } + + sleep(35); + + // check datbase stats + $headers = [ + 'origin' => 'http://localhost', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]; + $res = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/usage?range=30d', $headers); + $res = $res['body']; + + $this->assertEquals(8, count($res)); + $this->assertEquals(30, count($res['requests'])); + $this->assertEquals(30, count($res['storage'])); + $this->assertEquals($requestsCount, $res['requests'][array_key_last($res['requests'])]['value']); + $this->assertEquals($collectionsCount, $res['collections'][array_key_last($res['collections'])]['value']); + $this->assertEquals($documentsCount, $res['documents'][array_key_last($res['documents'])]['value']); + + $res = $this->client->call(Client::METHOD_GET, '/databases/usage?range=30d', array_merge($headers, [ + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin' + ])); + $res = $res['body']; + $this->assertEquals($databasesCount, $res['databasesCount'][array_key_last($res['databasesCount'])]['value']); + $this->assertEquals($collectionsCount, $res['collectionsCount'][array_key_last($res['collectionsCount'])]['value']); + $this->assertEquals($documentsCount, $res['documentsCount'][array_key_last($res['documentsCount'])]['value']); + + $this->assertEquals($databasesCreate, $res['databasesCreate'][array_key_last($res['databasesCreate'])]['value']); + $this->assertEquals($databasesRead, $res['databasesRead'][array_key_last($res['databasesRead'])]['value']); + $this->assertEquals($databasesDelete, $res['databasesDelete'][array_key_last($res['databasesDelete'])]['value']); + + $this->assertEquals($collectionsCreate, $res['collectionsCreate'][array_key_last($res['collectionsCreate'])]['value']); + $this->assertEquals($collectionsRead, $res['collectionsRead'][array_key_last($res['collectionsRead'])]['value']); + $this->assertEquals($collectionsUpdate, $res['collectionsUpdate'][array_key_last($res['collectionsUpdate'])]['value']); + $this->assertEquals($collectionsDelete, $res['collectionsDelete'][array_key_last($res['collectionsDelete'])]['value']); + + $this->assertEquals($documentsCreate, $res['documentsCreate'][array_key_last($res['documentsCreate'])]['value']); + $this->assertEquals($documentsRead, $res['documentsRead'][array_key_last($res['documentsRead'])]['value']); + $this->assertEquals($documentsDelete, $res['documentsDelete'][array_key_last($res['documentsDelete'])]['value']); + + $res = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/usage?range=30d', array_merge($headers, [ + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin' + ])); + $res = $res['body']; + $this->assertEquals($collectionsCount, $res['collectionsCount'][array_key_last($res['collectionsCount'])]['value']); + $this->assertEquals($documentsCount, $res['documentsCount'][array_key_last($res['documentsCount'])]['value']); + + $this->assertEquals($collectionsCreate, $res['collectionsCreate'][array_key_last($res['collectionsCreate'])]['value']); + $this->assertEquals($collectionsRead, $res['collectionsRead'][array_key_last($res['collectionsRead'])]['value']); + $this->assertEquals($collectionsUpdate, $res['collectionsUpdate'][array_key_last($res['collectionsUpdate'])]['value']); + $this->assertEquals($collectionsDelete, $res['collectionsDelete'][array_key_last($res['collectionsDelete'])]['value']); + + $this->assertEquals($documentsCreate, $res['documentsCreate'][array_key_last($res['documentsCreate'])]['value']); + $this->assertEquals($documentsRead, $res['documentsRead'][array_key_last($res['documentsRead'])]['value']); + $this->assertEquals($documentsDelete, $res['documentsDelete'][array_key_last($res['documentsDelete'])]['value']); + + $res = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/usage?range=30d', array_merge($headers, [ + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin' + ])); + $res = $res['body']; + $this->assertEquals($documentsCount, $res['documentsCount'][array_key_last($res['documentsCount'])]['value']); + + $this->assertEquals($documentsCreate, $res['documentsCreate'][array_key_last($res['documentsCreate'])]['value']); + $this->assertEquals($documentsRead, $res['documentsRead'][array_key_last($res['documentsRead'])]['value']); + $this->assertEquals($documentsDelete, $res['documentsDelete'][array_key_last($res['documentsDelete'])]['value']); + + $data['requestsCount'] = $requestsCount; + return $data; + } + + + /** @depends testDatabaseStats */ + public function testFunctionsStats(array $data): void + { + $headers = $data['headers']; + $functionId = ''; + $executionTime = 0; + $executions = 0; + $failures = 0; + + $response1 = $this->client->call(Client::METHOD_POST, '/functions', $headers, [ + 'functionId' => 'unique()', + 'name' => 'Test', + 'runtime' => 'php-8.0', + 'vars' => [ + 'funcKey1' => 'funcValue1', + 'funcKey2' => 'funcValue2', + 'funcKey3' => 'funcValue3', + ], + 'events' => [ + 'users.*.create', + 'users.*.delete', + ], + 'schedule' => '0 0 1 1 *', + 'timeout' => 10, + ]); + + $functionId = $response1['body']['$id'] ?? ''; + + $this->assertEquals(201, $response1['headers']['status-code']); + $this->assertNotEmpty($response1['body']['$id']); + + $code = realpath(__DIR__ . '/../../resources/functions') . "/php/code.tar.gz"; + $this->packageCode('php'); + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge($headers, ['content-type' => 'multipart/form-data',]), [ + 'entrypoint' => 'index.php', + 'code' => new CURLFile($code, 'application/x-gzip', \basename($code)), + 'activate' => true + ]); + + $deploymentId = $deployment['body']['$id'] ?? ''; + + $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertNotEmpty($deployment['body']['$id']); + $this->assertIsInt($deployment['body']['$createdAt']); + $this->assertEquals('index.php', $deployment['body']['entrypoint']); + + // Wait for deployment to build. + sleep(30); + + $response = $this->client->call(Client::METHOD_PATCH, '/functions/' . $functionId . '/deployments/' . $deploymentId, $headers, []); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertIsInt($response['body']['$createdAt']); + $this->assertIsInt($response['body']['$updatedAt']); + $this->assertEquals($deploymentId, $response['body']['deployment']); + + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', $headers, [ + 'async' => false, + ]); + + $this->assertEquals(201, $execution['headers']['status-code']); + $this->assertNotEmpty($execution['body']['$id']); + $this->assertEquals($functionId, $execution['body']['functionId']); + $executionTime += (int) ($execution['body']['time'] * 1000); + if ($execution['body']['status'] == 'failed') { + $failures++; + } elseif ($execution['body']['status'] == 'completed') { + $executions++; + } + + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', $headers, [ + 'async' => false, + ]); + + $this->assertEquals(201, $execution['headers']['status-code']); + $this->assertNotEmpty($execution['body']['$id']); + $this->assertEquals($functionId, $execution['body']['functionId']); + if ($execution['body']['status'] == 'failed') { + $failures++; + } elseif ($execution['body']['status'] == 'completed') { + $executions++; + } + $executionTime += (int) ($execution['body']['time'] * 1000); + + sleep(25); + + $response = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/usage', $headers, [ + 'range' => '30d' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(9, count($response['body'])); + $this->assertEquals('30d', $response['body']['range']); + $this->assertIsArray($response['body']['executionsTotal']); + $this->assertIsArray($response['body']['executionsFailure']); + $this->assertIsArray($response['body']['executionsSuccess']); + $this->assertIsArray($response['body']['executionsTime']); + $this->assertIsArray($response['body']['buildsTotal']); + $this->assertIsArray($response['body']['buildsFailure']); + $this->assertIsArray($response['body']['buildsSuccess']); + $this->assertIsArray($response['body']['buildsTime']); + $response = $response['body']; + + $this->assertEquals($executions, $response['executionsTotal'][array_key_last($response['executionsTotal'])]['value']); + $this->assertEquals($executionTime, $response['executionsTime'][array_key_last($response['executionsTime'])]['value']); + $this->assertEquals($failures, $response['executionsFailure'][array_key_last($response['executionsFailure'])]['value']); + + $response = $this->client->call(Client::METHOD_GET, '/functions/usage', $headers, [ + 'range' => '30d' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(9, count($response['body'])); + $this->assertEquals($response['body']['range'], '30d'); + $this->assertIsArray($response['body']['executionsTotal']); + $this->assertIsArray($response['body']['executionsFailure']); + $this->assertIsArray($response['body']['executionsSuccess']); + $this->assertIsArray($response['body']['executionsTime']); + $this->assertIsArray($response['body']['buildsTotal']); + $this->assertIsArray($response['body']['buildsFailure']); + $this->assertIsArray($response['body']['buildsSuccess']); + $this->assertIsArray($response['body']['buildsTime']); + $response = $response['body']; + + $this->assertEquals($executions, $response['executionsTotal'][array_key_last($response['executionsTotal'])]['value']); + $this->assertEquals($executionTime, $response['executionsTime'][array_key_last($response['executionsTime'])]['value']); + $this->assertGreaterThan(0, $response['buildsTime'][array_key_last($response['buildsTime'])]['value']); + $this->assertEquals($failures, $response['executionsFailure'][array_key_last($response['executionsFailure'])]['value']); + } + + protected function tearDown(): void + { + $this->usersCount = 0; + $this->requestsCount = 0; + $projectId = ''; + $headers = []; + } +} diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 6d5837e3c9..0a0c0a11a9 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -13,11 +13,12 @@ trait ProjectCustom protected static $project = []; /** + * @param bool $fresh * @return array */ - public function getProject(): array + public function getProject(bool $fresh = false): array { - if (!empty(self::$project)) { + if (!empty(self::$project) && !$fresh) { return self::$project; } @@ -116,13 +117,17 @@ trait ProjectCustom $this->assertEquals(201, $webhook['headers']['status-code']); $this->assertNotEmpty($webhook['body']); - self::$project = [ + $project = [ '$id' => $project['body']['$id'], 'name' => $project['body']['name'], 'apiKey' => $key['body']['secret'], 'webhookId' => $webhook['body']['$id'], 'signatureKey' => $webhook['body']['signatureKey'], ]; + if ($fresh) { + return $project; + } + self::$project = $project; return self::$project; } diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 9a6eb10cd1..5d31250205 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -48,13 +48,13 @@ trait DatabasesBase ]), [ 'collectionId' => ID::unique(), 'name' => 'Movies', + 'documentSecurity' => true, 'permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'documentSecurity' => true, ]); $this->assertEquals(201, $movies['headers']['status-code']); @@ -101,18 +101,20 @@ trait DatabasesBase ], ]); + $this->assertEquals(404, $responseCreateDocument['headers']['status-code']); + $responseListDocument = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); + $this->assertEquals(404, $responseListDocument['headers']['status-code']); + $responseGetDocument = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/someID', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); - $this->assertEquals(404, $responseCreateDocument['headers']['status-code']); - $this->assertEquals(404, $responseListDocument['headers']['status-code']); $this->assertEquals(404, $responseGetDocument['headers']['status-code']); } @@ -2199,7 +2201,7 @@ trait DatabasesBase $this->assertEquals([], $document['body']['$permissions']); } - // Updated and Inherit Permissions + // Updated Permissions $document = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $id, array_merge([ 'content-type' => 'application/json', @@ -2211,7 +2213,8 @@ trait DatabasesBase 'actors' => [], ], 'permissions' => [ - Permission::read(Role::any()), + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])) ], ]); @@ -2222,8 +2225,11 @@ trait DatabasesBase // This differs from the old permissions model because we don't inherit // existing document permissions on update, unless none were supplied, // so that specific types can be removed if wanted. - $this->assertCount(1, $document['body']['$permissions']); - $this->assertContains(Permission::read(Role::any()), $document['body']['$permissions']); + $this->assertCount(2, $document['body']['$permissions']); + $this->assertEquals([ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + ], $document['body']['$permissions']); $document = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $id, array_merge([ 'content-type' => 'application/json', @@ -2234,11 +2240,11 @@ trait DatabasesBase $this->assertEquals($document['body']['title'], 'Captain America 2'); $this->assertEquals($document['body']['releaseYear'], 1945); - // This differs from the old permissions model because we don't inherit - // existing document permissions on update, unless none were supplied, - // so that specific types can be removed if wanted. - $this->assertCount(1, $document['body']['$permissions']); - $this->assertContains(Permission::read(Role::any()), $document['body']['$permissions']); + $this->assertCount(2, $document['body']['$permissions']); + $this->assertEquals([ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + ], $document['body']['$permissions']); // Reset Permissions @@ -2260,10 +2266,18 @@ trait DatabasesBase $this->assertCount(0, $document['body']['$permissions']); $this->assertEquals([], $document['body']['$permissions']); + // Check user can still read document due to collection permissions of read("any") + $document = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $document['headers']['status-code']); + return $data; } - public function testEnforceDocumentPermissions(): void + public function testEnforceCollectionAndDocumentPermissions(): void { $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ 'content-type' => 'application/json', @@ -2271,10 +2285,10 @@ trait DatabasesBase 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'databaseId' => ID::unique(), - 'name' => 'EnforceCollectionPermissions', + 'name' => 'EnforceCollectionAndDocumentPermissions', ]); $this->assertEquals(201, $database['headers']['status-code']); - $this->assertEquals('EnforceCollectionPermissions', $database['body']['name']); + $this->assertEquals('EnforceCollectionAndDocumentPermissions', $database['body']['name']); $databaseId = $database['body']['$id']; $user = $this->getUser()['$id']; @@ -2284,7 +2298,7 @@ trait DatabasesBase 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'collectionId' => ID::unique(), - 'name' => 'enforceCollectionPermissions', + 'name' => 'enforceCollectionAndDocumentPermissions', 'documentSecurity' => true, 'permissions' => [ Permission::read(Role::user($user)), @@ -2295,7 +2309,7 @@ trait DatabasesBase ]); $this->assertEquals(201, $collection['headers']['status-code']); - $this->assertEquals($collection['body']['name'], 'enforceCollectionPermissions'); + $this->assertEquals($collection['body']['name'], 'enforceCollectionAndDocumentPermissions'); $this->assertEquals($collection['body']['documentSecurity'], true); $collectionId = $collection['body']['$id']; @@ -2389,22 +2403,16 @@ trait DatabasesBase 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); - switch ($this->getSide()) { - case 'client': - $this->assertEquals(2, $documentsUser1['body']['total']); - $this->assertCount(2, $documentsUser1['body']['documents']); - break; - case 'server': - $this->assertEquals(3, $documentsUser1['body']['total']); - $this->assertCount(3, $documentsUser1['body']['documents']); - break; - } + // Current user has read permission on the collection so can get any document + $this->assertEquals(3, $documentsUser1['body']['total']); + $this->assertCount(3, $documentsUser1['body']['documents']); $document3GetWithCollectionRead = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $document3['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); + // Current user has read permission on the collection so can get any document $this->assertEquals(200, $document3GetWithCollectionRead['headers']['status-code']); $email = uniqid() . 'user@localhost.test'; @@ -2437,6 +2445,7 @@ trait DatabasesBase 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session2, ]); + // Current user has no collection permissions but has read permission for this document $this->assertEquals(200, $document3GetWithDocumentRead['headers']['status-code']); $document2GetFailure = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $document2['body']['$id'], [ @@ -2446,7 +2455,8 @@ trait DatabasesBase 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session2, ]); - $this->assertEquals(401, $document2GetFailure['headers']['status-code']); + // Current user has no collection or document permissions for this document + $this->assertEquals(404, $document2GetFailure['headers']['status-code']); $documentsUser2 = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ 'origin' => 'http://localhost', @@ -2455,6 +2465,210 @@ trait DatabasesBase 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session2, ]); + // Current user has no collection permissions but has read permission for one document + $this->assertEquals(1, $documentsUser2['body']['total']); + $this->assertCount(1, $documentsUser2['body']['documents']); + } + + public function testEnforceCollectionPermissions() + { + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'EnforceCollectionPermissions', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('EnforceCollectionPermissions', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $user = $this->getUser()['$id']; + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'enforceCollectionPermissions', + 'permissions' => [ + Permission::read(Role::user($user)), + Permission::create(Role::user($user)), + Permission::update(Role::user($user)), + Permission::delete(Role::user($user)), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $this->assertEquals($collection['body']['name'], 'enforceCollectionPermissions'); + $this->assertEquals($collection['body']['documentSecurity'], false); + + $collectionId = $collection['body']['$id']; + + sleep(2); + + $attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'attribute', + 'size' => 64, + 'required' => true, + ]); + + $this->assertEquals(202, $attribute['headers']['status-code'], 202); + $this->assertEquals('attribute', $attribute['body']['key']); + + // wait for db to add attribute + sleep(2); + + $index = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/indexes', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'key_attribute', + 'type' => 'key', + 'attributes' => [$attribute['body']['key']], + ]); + + $this->assertEquals(202, $index['headers']['status-code']); + $this->assertEquals('key_attribute', $index['body']['key']); + + // wait for db to add attribute + sleep(2); + + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'attribute' => 'one', + ], + 'permissions' => [ + Permission::read(Role::user($user)), + Permission::update(Role::user($user)), + Permission::delete(Role::user($user)), + ] + ]); + + $this->assertEquals(201, $document1['headers']['status-code']); + + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'attribute' => 'one', + ], + 'permissions' => [ + Permission::update(Role::user($user)), + Permission::delete(Role::user($user)), + ] + ]); + + $this->assertEquals(201, $document2['headers']['status-code']); + + $document3 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'attribute' => 'one', + ], + 'permissions' => [ + Permission::read(Role::user(ID::custom('other2'))), + Permission::update(Role::user(ID::custom('other2'))), + ], + ]); + + $this->assertEquals(201, $document3['headers']['status-code']); + + $documentsUser1 = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + // Current user has read permission on the collection so can get any document + $this->assertEquals(3, $documentsUser1['body']['total']); + $this->assertCount(3, $documentsUser1['body']['documents']); + + $document3GetWithCollectionRead = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $document3['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + // Current user has read permission on the collection so can get any document + $this->assertEquals(200, $document3GetWithCollectionRead['headers']['status-code']); + + $email = uniqid() . 'user2@localhost.test'; + $password = 'password'; + $name = 'User Name'; + $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => ID::custom('other2'), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + $session2 = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + $session2 = $this->client->parseCookie((string)$session2['headers']['set-cookie'])['a_session_' . $this->getProject()['$id']]; + + $document3GetWithDocumentRead = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $document3['body']['$id'], [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session2, + ]); + + // Current user has no collection permissions and document permissions are disabled + $this->assertEquals(401, $document3GetWithDocumentRead['headers']['status-code']); + + $documentsUser2 = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session2, + ]); + + // Current user has no collection permissions and document permissions are disabled + $this->assertEquals(401, $documentsUser2['headers']['status-code']); + + + // Enable document permissions + $collection = $this->client->call(CLient::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => $collection['body']['name'], + 'documentSecurity' => true, + ]); + + $documentsUser2 = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session2, + ]); + + // Current user has no collection permissions read access to one document $this->assertEquals(1, $documentsUser2['body']['total']); $this->assertCount(1, $documentsUser2['body']['documents']); } diff --git a/tests/e2e/Services/Databases/DatabasesPermissionsGuestTest.php b/tests/e2e/Services/Databases/DatabasesPermissionsGuestTest.php index 3a079c6d22..8d4a19bd30 100644 --- a/tests/e2e/Services/Databases/DatabasesPermissionsGuestTest.php +++ b/tests/e2e/Services/Databases/DatabasesPermissionsGuestTest.php @@ -9,6 +9,7 @@ use Tests\E2E\Scopes\SideClient; use Utopia\Database\ID; use Utopia\Database\Permission; use Utopia\Database\Role; +use Utopia\Database\Validator\Authorization; class DatabasesPermissionsGuestTest extends Scope { @@ -88,19 +89,19 @@ class DatabasesPermissionsGuestTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); + $roles = Authorization::getRoles(); + Authorization::cleanRoles(); + $documents = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]); - foreach ($documents['body']['documents'] as $document) { - foreach ($document['$permissions'] as $permission) { - $permission = Permission::parse($permission); - if ($permission->getPermission() != 'read') { - continue; - } - $this->assertEquals($permission->getRole(), Role::any()->toString()); - } + $this->assertEquals(1, $documents['body']['total']); + $this->assertEquals($permissions, $documents['body']['documents'][0]['$permissions']); + + foreach ($roles as $role) { + Authorization::setRole($role); } } } diff --git a/tests/e2e/Services/Databases/DatabasesPermissionsMemberTest.php b/tests/e2e/Services/Databases/DatabasesPermissionsMemberTest.php index 29042178ce..aedc53854a 100644 --- a/tests/e2e/Services/Databases/DatabasesPermissionsMemberTest.php +++ b/tests/e2e/Services/Databases/DatabasesPermissionsMemberTest.php @@ -3,8 +3,8 @@ namespace Tests\E2E\Services\Databases; use Tests\E2E\Client; -use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\ProjectCustom; +use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; use Utopia\Database\ID; use Utopia\Database\Permission; @@ -29,16 +29,72 @@ class DatabasesPermissionsMemberTest extends Scope public function permissionsProvider(): array { return [ - [[Permission::read(Role::any())]], - [[Permission::read(Role::users())]], - [[Permission::read(Role::user(ID::custom('random')))]], - [[Permission::read(Role::user(ID::custom('lorem'))), Permission::update(Role::user('lorem')), Permission::delete(Role::user('lorem'))]], - [[Permission::read(Role::user(ID::custom('dolor'))), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))]], - [[Permission::read(Role::user(ID::custom('dolor'))), Permission::read(Role::user('lorem')), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))]], - [[Permission::update(Role::any()), Permission::delete(Role::any())]], - [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]], - [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())]], - [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())]], + [ + 'permissions' => [Permission::read(Role::any())], + 'any' => 1, + 'users' => 1, + 'doconly' => 1, + ], + [ + 'permissions' => [Permission::read(Role::users())], + 'any' => 2, + 'users' => 2, + 'doconly' => 2, + ], + [ + 'permissions' => [Permission::read(Role::user(ID::custom('random')))], + 'any' => 3, + 'users' => 3, + 'doconly' => 2, + ], + [ + 'permissions' => [Permission::read(Role::user(ID::custom('lorem'))), Permission::update(Role::user('lorem')), Permission::delete(Role::user('lorem'))], + 'any' => 4, + 'users' => 4, + 'doconly' => 2, + ], + [ + 'permissions' => [Permission::read(Role::user(ID::custom('dolor'))), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], + 'any' => 5, + 'users' => 5, + 'doconly' => 2, + ], + [ + 'permissions' => [Permission::read(Role::user(ID::custom('dolor'))), Permission::read(Role::user('lorem')), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], + 'any' => 6, + 'users' => 6, + 'doconly' => 2, + ], + [ + 'permissions' => [Permission::update(Role::any()), Permission::delete(Role::any())], + 'any' => 7, + 'users' => 7, + 'doconly' => 2, + ], + [ + 'permissions' => [Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], + 'any' => 8, + 'users' => 8, + 'doconly' => 3, + ], + [ + 'permissions' => [Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())], + 'any' => 9, + 'users' => 9, + 'doconly' => 4, + ], + [ + 'permissions' => [Permission::read(Role::user(ID::custom('user1')))], + 'any' => 10, + 'users' => 10, + 'doconly' => 5, + ], + [ + 'permissions' => [Permission::read(Role::user(ID::custom('user1'))), Permission::read(Role::user(ID::custom('user1')))], + 'any' => 11, + 'users' => 11, + 'doconly' => 6, + ], ]; } @@ -74,7 +130,6 @@ class DatabasesPermissionsMemberTest extends Scope 'documentSecurity' => true, ]); $this->assertEquals(201, $public['headers']['status-code']); - $this->collections = ['public' => $public['body']['$id']]; $response = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $this->collections['public'] . '/attributes/string', $this->getServerHeader(), [ @@ -96,10 +151,25 @@ class DatabasesPermissionsMemberTest extends Scope 'documentSecurity' => true, ]); $this->assertEquals(201, $private['headers']['status-code']); - $this->collections['private'] = $private['body']['$id']; - $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $this->collections['private'] . '/attributes/string', $this->getServerHeader(), [ + $response = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $this->collections['private'] . '/attributes/string', $this->getServerHeader(), [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $response['headers']['status-code']); + + $doconly = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Document Only Movies', + 'permissions' => [], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['doconly'] = $doconly['body']['$id']; + + $response = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $this->collections['doconly'] . '/attributes/string', $this->getServerHeader(), [ 'key' => 'title', 'size' => 256, 'required' => true, @@ -118,9 +188,9 @@ class DatabasesPermissionsMemberTest extends Scope /** * Data provider params are passed before test dependencies * @dataProvider permissionsProvider - * @depends testSetupDatabase + * @depends testSetupDatabase */ - public function testReadDocuments($permissions, $data) + public function testReadDocuments($permissions, $anyCount, $usersCount, $docOnlyCount, $data) { $users = $data['users']; $collections = $data['collections']; @@ -144,66 +214,52 @@ class DatabasesPermissionsMemberTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); + $response = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collections['doconly'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + /** - * Check "any" collection + * Check "any" permission collection */ - $documents = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collections['public'] . '/documents', [ + $documents = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collections['public'] . '/documents', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], ]); - foreach ($documents['body']['documents'] as $document) { - $hasPermissions = \array_reduce([ - Role::any()->toString(), - Role::users()->toString(), - Role::user($users['user1']['$id'])->toString(), - ], function (bool $carry, string $role) use ($document) { - if ($carry) { - return true; - } - foreach ($document['$permissions'] as $permission) { - $permission = Permission::parse($permission); - if ($permission->getPermission() == 'read' && $permission->getRole() == $role) { - return true; - } - } - return false; - }, false); - - $this->assertTrue($hasPermissions); - } + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($anyCount, $documents['body']['total']); /** - * Check role:member collection + * Check "users" permission collection */ - $documents = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collections['private'] . '/documents', [ + $documents = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collections['private'] . '/documents', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], ]); - foreach ($documents['body']['documents'] as $document) { - $hasPermissions = \array_reduce([ - Role::any()->toString(), - Role::users()->toString(), - Role::user($users['user1']['$id'])->toString(), - ], function (bool $carry, string $role) use ($document) { - if ($carry) { - return true; - } - foreach ($document['$permissions'] as $permission) { - $permission = Permission::parse($permission); - if ($permission->getPermission() == 'read' && $permission->getRole() == $role) { - return true; - } - } - return false; - }, false); + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($usersCount, $documents['body']['total']); - $this->assertTrue($hasPermissions); - } + /** + * Check "user:user1" document only permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collections['doconly'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($docOnlyCount, $documents['body']['total']); } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 7dc4a8f0f9..1b0ee751d6 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -276,7 +276,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('30d', $response['body']['range']); $this->assertIsArray($response['body']['requests']); $this->assertIsArray($response['body']['network']); - $this->assertIsArray($response['body']['functions']); + $this->assertIsArray($response['body']['executions']); $this->assertIsArray($response['body']['documents']); $this->assertIsArray($response['body']['collections']); $this->assertIsArray($response['body']['users']); diff --git a/tests/e2e/Services/Storage/StorageConsoleClientTest.php b/tests/e2e/Services/Storage/StorageConsoleClientTest.php index e8520c7549..8b4aaa1fca 100644 --- a/tests/e2e/Services/Storage/StorageConsoleClientTest.php +++ b/tests/e2e/Services/Storage/StorageConsoleClientTest.php @@ -39,9 +39,9 @@ class StorageConsoleClientTest extends Scope ]); $this->assertEquals($response['headers']['status-code'], 200); - $this->assertEquals(count($response['body']), 13); + $this->assertEquals(12, count($response['body'])); $this->assertEquals($response['body']['range'], '24h'); - $this->assertIsArray($response['body']['filesStorage']); + $this->assertIsArray($response['body']['storage']); $this->assertIsArray($response['body']['filesCount']); } diff --git a/tests/unit/Stats/StatsTest.php b/tests/unit/Usage/StatsTest.php similarity index 98% rename from tests/unit/Stats/StatsTest.php rename to tests/unit/Usage/StatsTest.php index 7aee868855..29db96754f 100644 --- a/tests/unit/Stats/StatsTest.php +++ b/tests/unit/Usage/StatsTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit\Stats; -use Appwrite\Stats\Stats; +use Appwrite\Usage\Stats; use PHPUnit\Framework\TestCase; use Utopia\App;