From eb9c42a5044edd2bcb2a99ffff4c8c2ace441323 Mon Sep 17 00:00:00 2001 From: Claudio Costa Date: Wed, 18 Sep 2024 08:00:46 -0600 Subject: [PATCH] [MM-59996] Make stats and client logs more easily accessible (#861) * [MM-59996] Make stats and client logs more easily accessible through slash commands (#840) * Make stats and client logs more easily accessible through slash commands * Util function * Command response wrapper * Fix e2e * Revert "Fix e2e" This reverts commit 77104719fcfac261505aa12f246cfaf189ad3047. * Use Playbooks v2 * Fix e2e tests pipeline (#859) * update e2e test pipeline --- .github/workflows/e2e.yml | 48 ++++++++++++++- .github/workflows/generate-e2e-report.yml | 2 - e2e/config-patch.json | 9 +++ e2e/overlay-config.jq | 16 +---- e2e/scripts/prepare-server.sh | 21 ++++--- e2e/scripts/run.sh | 30 ++++++++- e2e/tests/desktop.spec.ts | 43 +++++++++++++ server/slash_command.go | 75 ++++++++++------------- webapp/src/client.ts | 24 +++++--- webapp/src/constants.ts | 7 +++ webapp/src/log.ts | 37 ++++++++++- webapp/src/slash_commands.tsx | 19 ++++-- webapp/src/utils.ts | 7 ++- 13 files changed, 249 insertions(+), 89 deletions(-) create mode 100644 e2e/config-patch.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6ea7b717a..48da6a95c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -52,6 +52,33 @@ jobs: compression-level: 0 retention-days: 1 + build-calls-transcriber: + runs-on: ubuntu-22.04 + steps: + - name: e2e/checkout-transcriber-repo + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + repository: mattermost/calls-transcriber + path: calls-transcriber + + - name: e2e/setup-docker-buildx + uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb # v3.3.0 + + - name: e2e/build-image + working-directory: ./calls-transcriber + run: | + make docker-build CI=false + docker save --output calls-transcriber.tar calls-transcriber:master + + - name: e2e/persist-mattermost-calls-transcriber-image + uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 + with: + name: mattermost-plugin-calls-transcriber-image + path: ${{ github.workspace }}/calls-transcriber/calls-transcriber.tar + if-no-files-found: error + compression-level: 0 + retention-days: 1 + generate-matrix: runs-on: ubuntu-22.04 outputs: @@ -68,14 +95,14 @@ jobs: runs-on: ubuntu-22.04 needs: - build-mattermost-plugin-calls + - build-calls-transcriber - generate-matrix env: COMPOSE_PROJECT_NAME: playwright_tests DOCKER_NETWORK: playwright_tests CONTAINER_SERVER: playwright_tests_server IMAGE_CALLS_OFFLOADER: mattermost/calls-offloader:v0.8.0 - IMAGE_CALLS_RECORDER: mattermost/calls-recorder:v0.7.3 - IMAGE_CALLS_TRANSCRIBER: mattermost/calls-transcriber:v0.3.1 + IMAGE_CALLS_RECORDER: mattermost/calls-recorder:v0.7.5 IMAGE_SERVER: mattermostdevelopment/mattermost-enterprise-edition:master IMAGE_CURL: curlimages/curl:8.7.1 CI_NODE_INDEX: ${{ matrix.run_id }} @@ -122,13 +149,27 @@ jobs: name: mattermost-plugin-calls-package path: dist + - name: e2e/download-calls-transcriber-image + uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 + with: + name: mattermost-plugin-calls-transcriber-image + path: ${{ github.workspace }} + + - name: e2e/docker-login + uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0 + # Do not authenticate on Forks + if: github.event.pull_request.head.repo.full_name == github.repository + with: + username: ${{ secrets.DOCKERHUB_DEV_USERNAME }} + password: ${{ secrets.DOCKERHUB_DEV_TOKEN }} + - name: e2e/prepare-server env: DOCKER_CLIENT_TIMEOUT: 120 COMPOSE_HTTP_TIMEOUT: 120 ## Should relative to mattermost/server/build/ DOCKER_COMPOSE_FILE: gitlab-dc.postgres.yml - DOCKER_COMPOSE_TEST_DATA: ../tests/test-data.ldif + TRANSCRIBER_IMAGE_PATH: ${{ github.workspace }}/calls-transcriber.tar run: | mkdir -p ${{ github.workspace }}/logs mkdir -p ${{ github.workspace }}/config @@ -143,6 +184,7 @@ jobs: echo "MM_LICENSE=${{ secrets.MM_PLUGIN_CALLS_TEST_LICENSE }}" >> dotenv/app.private.env echo "MM_FEATUREFLAGS_BoardsProduct=true" >> dotenv/app.private.env echo "MM_SERVICEENVIRONMENT=test" >> dotenv/app.private.env + echo "MM_CALLS_JOB_SERVICE_URL=http://calls-offloader:4545" >> dotenv/app.private.env sudo chown -R 2000:2000 ${{ github.workspace }}/logs sudo chown -R 2000:2000 ${{ github.workspace }}/config diff --git a/.github/workflows/generate-e2e-report.yml b/.github/workflows/generate-e2e-report.yml index bac9a8cb9..a3386ec0a 100644 --- a/.github/workflows/generate-e2e-report.yml +++ b/.github/workflows/generate-e2e-report.yml @@ -14,8 +14,6 @@ jobs: steps: - name: e2e-report/checkout-mattermost-plugin-calls-repo uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - with: - ref: ${{ github.event.workflow_run.head_sha }} - name: e2e-report/download-paywright-report-results uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 diff --git a/e2e/config-patch.json b/e2e/config-patch.json new file mode 100644 index 000000000..94bbf50e8 --- /dev/null +++ b/e2e/config-patch.json @@ -0,0 +1,9 @@ +{ + "PluginSettings": { + "Plugins": { + "com.mattermost.calls": { + "enablerecordings": true + } + } + } +} diff --git a/e2e/overlay-config.jq b/e2e/overlay-config.jq index 98f7d03bd..a103b44f3 100644 --- a/e2e/overlay-config.jq +++ b/e2e/overlay-config.jq @@ -13,19 +13,10 @@ "EmailSettings": { "SMTPServer": "inbucket" }, - # Disable automatic installation of prepackaged plugins "PluginSettings": { "EnableUploads": true, - "AutomaticPrepackagedPlugins": false, - "Plugins": { - "com.mattermost.calls": { - "icehostoverride": "", - "iceserversconfigs": "", - "enablerecordings": true, - "defaultenabled": true, - "jobserviceurl": "http://calls-offloader:4545" - } - } + # Disable automatic installation of prepackaged plugins + "AutomaticPrepackagedPlugins": false }, "ServiceSettings": { "SiteURL": "http://mm-server:8065", @@ -47,9 +38,6 @@ "UserNoticesEnabled": false, "AdminNoticesEnabled": false }, - "FeatureFlags": { - "CallsEnabled": true - }, "ExperimentalSettings": { "DisableAppBar": false }, diff --git a/e2e/scripts/prepare-server.sh b/e2e/scripts/prepare-server.sh index 45c15d19e..38ff74e7a 100755 --- a/e2e/scripts/prepare-server.sh +++ b/e2e/scripts/prepare-server.sh @@ -6,21 +6,24 @@ docker network create ${DOCKER_NETWORK} # Start server dependencies echo "Starting server dependencies ... " -docker-compose -f ${DOCKER_COMPOSE_FILE} run -d --rm start_dependencies -timeout --foreground 90s bash -c "until docker-compose -f ${DOCKER_COMPOSE_FILE} exec -T postgres pg_isready ; do sleep 5 ; done" +docker compose -f ${DOCKER_COMPOSE_FILE} run -d --rm start_dependencies +timeout --foreground 90s bash -c "until docker compose -f ${DOCKER_COMPOSE_FILE} exec -T postgres pg_isready ; do sleep 5 ; done" +docker compose -f ${DOCKER_COMPOSE_FILE} exec -d -T minio sh -c 'mkdir -p /data/mattermost-test' -cat ${DOCKER_COMPOSE_TEST_DATA} | docker-compose -f ${DOCKER_COMPOSE_FILE} exec -T openldap bash -c 'ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest' -docker-compose -f ${DOCKER_COMPOSE_FILE} exec -d -T minio sh -c 'mkdir -p /data/mattermost-test' +echo "Pulling ${IMAGE_CALLS_RECORDER} in order to be quickly accessible ... " +# Pull calls-recorder image to be used by calls-offloader. +docker pull ${IMAGE_CALLS_RECORDER} + +## Load calls-transcriber image +docker load --input ${TRANSCRIBER_IMAGE_PATH} -echo "Pulling ${IMAGE_CALLS_RECORDER} and ${IMAGE_CALLS_TRANSCRIBER} in order to be quickly accessible ... " -# Pull calls-recorder and calls-transcriber images to be used by calls-offloader. -docker pull --quiet ${IMAGE_CALLS_RECORDER} -docker pull --quiet ${IMAGE_CALLS_TRANSCRIBER} # We retag the official images so they can be run instead of the expected local # one (DEV_MODE=true). Alternatively we'd have to build our own image from scratch or make # some CI specific changes on the offloader. docker image tag ${IMAGE_CALLS_RECORDER} calls-recorder:master -docker image tag ${IMAGE_CALLS_TRANSCRIBER} calls-transcriber:master + +## Print images info +docker images echo "Spawning calls-offloader service with docker host access ..." # Spawn calls offloader image as root to access local docker socket diff --git a/e2e/scripts/run.sh b/e2e/scripts/run.sh index 0a9c753b4..1b3bb5647 100755 --- a/e2e/scripts/run.sh +++ b/e2e/scripts/run.sh @@ -6,18 +6,28 @@ set -o pipefail echo "Installing playbooks ..." docker exec \ ${CONTAINER_SERVER} \ - sh -c "/mattermost/bin/mmctl --local plugin add /mattermost/prepackaged_plugins/mattermost-plugin-playbooks-*.tar.gz && /mattermost/bin/mmctl --local plugin enable playbooks" + sh -c "/mattermost/bin/mmctl --local plugin add /mattermost/prepackaged_plugins/mattermost-plugin-playbooks-v2*.tar.gz && /mattermost/bin/mmctl --local plugin enable playbooks" # Copy built plugin into server echo "Copying calls plugin into ${CONTAINER_SERVER} server container ..." docker cp dist/*.tar.gz ${CONTAINER_SERVER}:/mattermost/bin/calls +# Copy config patch into server container +echo "Copying calls config patch into ${CONTAINER_SERVER} server container ..." +docker cp e2e/config-patch.json ${CONTAINER_SERVER}:/mattermost + # Install Calls echo "Installing calls ..." docker exec \ ${CONTAINER_SERVER} \ sh -c "/mattermost/bin/mmctl --local plugin add bin/calls" +# Patch config +echo "Patching calls config ..." +docker exec \ + ${CONTAINER_SERVER} \ + sh -c "/mattermost/bin/mmctl --local plugin disable com.mattermost.calls && /mattermost/bin/mmctl --local config patch /mattermost/config-patch.json && /mattermost/bin/mmctl --local plugin enable com.mattermost.calls" + # Generates a sysadmin that Playwright can use echo "Generating sample data with mmctl ..." docker exec \ @@ -41,6 +51,24 @@ docker run -d --name playwright-e2e \ docker logs -f playwright-e2e +# Log all containers +docker ps -a + +# Offloader logs +docker logs "${COMPOSE_PROJECT_NAME}_callsoffloader" + +# Print transcriber job logs in case of failure. +for ID in $(docker ps -a --filter=ancestor="calls-transcriber:master" --format "{{.ID}}") +do + docker logs $ID +done + +# Print recorder job logs in case of failure. +for ID in $(docker ps -a --filter=ancestor="calls-recorder:master" --format "{{.ID}}") +do + docker logs $ID +done + docker cp playwright-e2e:/usr/src/calls-e2e/test-results results/test-results-${CI_NODE_INDEX} docker cp playwright-e2e:/usr/src/calls-e2e/playwright-report results/playwright-report-${CI_NODE_INDEX} docker cp playwright-e2e:/usr/src/calls-e2e/pw-results.json results/pw-results-${CI_NODE_INDEX}.json diff --git a/e2e/tests/desktop.spec.ts b/e2e/tests/desktop.spec.ts index 59a38b7f1..d4eff1825 100644 --- a/e2e/tests/desktop.spec.ts +++ b/e2e/tests/desktop.spec.ts @@ -238,4 +238,47 @@ test.describe('desktop', () => { // Verify error is getting sent expect(desktopAPICalls.leaveCall).toBe(true); }); + + test('desktop: /call stats command', async ({page}) => { + // start call in global widget + const devPage = new PlaywrightDevPage(page); + await devPage.openWidget(getChannelNamesForTest()[0]); + await devPage.leaveCall(); + + // Need to wait a moment since the the leave call happens in + // a setTimeout handler. + await devPage.wait(500); + + // Go back to center channel view + await devPage.goto(); + + // Issue slash command + await devPage.sendSlashCommand('/call stats'); + await devPage.wait(500); + + // Veirfy call stats have been returned + await expect(page.locator('.post__body').last()).toContainText('"initTime"'); + await expect(page.locator('.post__body').last()).toContainText('"callID"'); + }); + + test('desktop: /call logs command', async ({page}) => { + // start call in global widget + const devPage = new PlaywrightDevPage(page); + await devPage.openWidget(getChannelNamesForTest()[0]); + await devPage.leaveCall(); + + // Need to wait a moment since the the leave call happens in + // a setTimeout handler. + await devPage.wait(500); + + // Go back to center channel view + await devPage.goto(); + + // Issue slash command + await devPage.sendSlashCommand('/call logs'); + await devPage.wait(500); + + // Veirfy call logs have been returned + await expect(page.locator('.post__body').last()).toContainText('join ack received, initializing connection'); + }); }); diff --git a/server/slash_command.go b/server/slash_command.go index ef3cab55c..4120cb630 100644 --- a/server/slash_command.go +++ b/server/slash_command.go @@ -25,6 +25,7 @@ const ( endCommandTrigger = "end" recordingCommandTrigger = "recording" hostCommandTrigger = "host" + logsCommandTrigger = "logs" ) var subCommands = []string{ @@ -36,6 +37,7 @@ var subCommands = []string{ endCommandTrigger, statsCommandTrigger, recordingCommandTrigger, + logsCommandTrigger, } func (p *Plugin) getAutocompleteData() *model.AutocompleteData { @@ -49,6 +51,7 @@ func (p *Plugin) getAutocompleteData() *model.AutocompleteData { data.AddCommand(model.NewAutocompleteData(linkCommandTrigger, "", "Generate a link to join a call in the current channel.")) data.AddCommand(model.NewAutocompleteData(statsCommandTrigger, "", "Show client-generated statistics about the call.")) data.AddCommand(model.NewAutocompleteData(endCommandTrigger, "", "End the call for everyone. All the participants will drop immediately.")) + data.AddCommand(model.NewAutocompleteData(logsCommandTrigger, "", "Show client logs.")) experimentalCmdData := model.NewAutocompleteData(experimentalCommandTrigger, "", "Turn experimental features on or off.") experimentalCmdData.AddTextArgument("Available options: on, off", "", "on|off") @@ -157,6 +160,22 @@ func handleStatsCommand(fields []string) (*model.CommandResponse, error) { }, nil } +func handleLogsCommand(fields []string) (*model.CommandResponse, error) { + if len(fields) < 3 { + return nil, fmt.Errorf("Empty logs") + } + + logs, err := base64.StdEncoding.DecodeString(fields[2]) + if err != nil { + return nil, fmt.Errorf("Failed to decode payload: %w", err) + } + + return &model.CommandResponse{ + ResponseType: model.CommandResponseTypeEphemeral, + Text: fmt.Sprintf("```\n%s\n```", logs), + }, nil +} + func (p *Plugin) handleEndCallCommand() (*model.CommandResponse, error) { return &model.CommandResponse{}, nil } @@ -212,8 +231,7 @@ func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*mo subCmd := fields[1] - if subCmd == linkCommandTrigger { - resp, err := p.handleLinkCommand(args) + buildCommandResponse := func(resp *model.CommandResponse, err error) (*model.CommandResponse, *model.AppError) { if err != nil { return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, @@ -223,59 +241,32 @@ func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*mo return resp, nil } + if subCmd == linkCommandTrigger { + return buildCommandResponse(p.handleLinkCommand(args)) + } + if subCmd == experimentalCommandTrigger { - resp, err := handleExperimentalCommand(fields) - if err != nil { - return &model.CommandResponse{ - ResponseType: model.CommandResponseTypeEphemeral, - Text: fmt.Sprintf("Error: %s", err.Error()), - }, nil - } - return resp, nil + return buildCommandResponse(handleExperimentalCommand(fields)) } if subCmd == statsCommandTrigger { - resp, err := handleStatsCommand(fields) - if err != nil { - return &model.CommandResponse{ - ResponseType: model.CommandResponseTypeEphemeral, - Text: fmt.Sprintf("Error: %s", err.Error()), - }, nil - } - return resp, nil + return buildCommandResponse(handleStatsCommand(fields)) + } + + if subCmd == logsCommandTrigger { + return buildCommandResponse(handleLogsCommand(fields)) } if subCmd == endCommandTrigger { - resp, err := p.handleEndCallCommand() - if err != nil { - return &model.CommandResponse{ - ResponseType: model.CommandResponseTypeEphemeral, - Text: fmt.Sprintf("Error: %s", err.Error()), - }, nil - } - return resp, nil + return buildCommandResponse(p.handleEndCallCommand()) } if subCmd == recordingCommandTrigger { - resp, err := p.handleRecordingCommand(fields) - if err != nil { - return &model.CommandResponse{ - ResponseType: model.CommandResponseTypeEphemeral, - Text: fmt.Sprintf("Error: %s", err.Error()), - }, nil - } - return resp, nil + return buildCommandResponse(p.handleRecordingCommand(fields)) } if subCmd == hostCommandTrigger && p.licenseChecker.HostControlsAllowed() { - resp, err := p.handleHostCommand(args, fields) - if err != nil { - return &model.CommandResponse{ - ResponseType: model.CommandResponseTypeEphemeral, - Text: fmt.Sprintf("Error: %s", err.Error()), - }, nil - } - return resp, nil + return buildCommandResponse(p.handleHostCommand(args, fields)) } for _, cmd := range subCommands { diff --git a/webapp/src/client.ts b/webapp/src/client.ts index e5e0a6272..800620ed5 100644 --- a/webapp/src/client.ts +++ b/webapp/src/client.ts @@ -8,9 +8,14 @@ import {EventEmitter} from 'events'; import {deflate} from 'pako/lib/deflate'; import {AudioDevices, CallsClientConfig, CallsClientJoinData, CallsClientStats, TrackInfo} from 'src/types/types'; -import {logDebug, logErr, logInfo, logWarn} from './log'; -import {getScreenStream} from './utils'; +import {logDebug, logErr, logInfo, logWarn, persistClientLogs} from './log'; +import {getScreenStream, getPersistentStorage} from './utils'; import {WebSocketClient, WebSocketError, WebSocketErrorType} from './websocket'; +import { + STORAGE_CALLS_CLIENT_STATS_KEY, + STORAGE_CALLS_DEFAULT_AUDIO_INPUT_KEY, + STORAGE_CALLS_DEFAULT_AUDIO_OUTPUT_KEY, +} from 'src/constants'; export const AudioInputPermissionsError = new Error('missing audio input permissions'); export const AudioInputMissingError = new Error('no audio input available'); @@ -97,8 +102,8 @@ export default class CallsClient extends EventEmitter { }; } - const defaultInputID = window.localStorage.getItem('calls_default_audio_input'); - const defaultOutputID = window.localStorage.getItem('calls_default_audio_output'); + const defaultInputID = window.localStorage.getItem(STORAGE_CALLS_DEFAULT_AUDIO_INPUT_KEY); + const defaultOutputID = window.localStorage.getItem(STORAGE_CALLS_DEFAULT_AUDIO_OUTPUT_KEY); if (defaultInputID && !this.currentAudioInputDevice) { const devices = this.audioDevices.inputs.filter((dev) => { return dev.deviceId === defaultInputID; @@ -112,7 +117,7 @@ export default class CallsClient extends EventEmitter { this.currentAudioInputDevice = devices[0]; } else { logDebug('audio input device not found'); - window.localStorage.removeItem('calls_default_audio_input'); + window.localStorage.removeItem(STORAGE_CALLS_DEFAULT_AUDIO_INPUT_KEY); } } @@ -126,7 +131,7 @@ export default class CallsClient extends EventEmitter { this.currentAudioOutputDevice = devices[0]; } else { logDebug('audio output device not found'); - window.localStorage.removeItem('calls_default_audio_output'); + window.localStorage.removeItem(STORAGE_CALLS_DEFAULT_AUDIO_OUTPUT_KEY); } } @@ -333,6 +338,7 @@ export default class CallsClient extends EventEmitter { this.removeAllListeners('mos'); window.removeEventListener('beforeunload', this.onBeforeUnload); navigator.mediaDevices?.removeEventListener('devicechange', this.onDeviceChange); + persistClientLogs(); } public async setAudioInputDevice(device: MediaDeviceInfo) { @@ -340,7 +346,7 @@ export default class CallsClient extends EventEmitter { return; } - window.localStorage.setItem('calls_default_audio_input', device.deviceId); + window.localStorage.setItem(STORAGE_CALLS_DEFAULT_AUDIO_INPUT_KEY, device.deviceId); this.currentAudioInputDevice = device; // We emit this event so it's easier to keep state in sync between widget and pop out. @@ -390,7 +396,7 @@ export default class CallsClient extends EventEmitter { if (!this.peer) { return; } - window.localStorage.setItem('calls_default_audio_output', device.deviceId); + window.localStorage.setItem(STORAGE_CALLS_DEFAULT_AUDIO_OUTPUT_KEY, device.deviceId); this.currentAudioOutputDevice = device; // We emit this event so it's easier to keep state in sync between widget and pop out. @@ -410,7 +416,7 @@ export default class CallsClient extends EventEmitter { this.closed = true; if (this.peer) { this.getStats().then((stats) => { - sessionStorage.setItem('calls_client_stats', JSON.stringify(stats)); + getPersistentStorage().setItem(STORAGE_CALLS_CLIENT_STATS_KEY, JSON.stringify(stats)); }).catch((statsErr) => { logErr(statsErr); }); diff --git a/webapp/src/constants.ts b/webapp/src/constants.ts index b5fba0c8f..46547de43 100644 --- a/webapp/src/constants.ts +++ b/webapp/src/constants.ts @@ -82,3 +82,10 @@ export const CallTranscribingDisclaimerStrings: {[key: string]: {[key: string]: export const DisabledCallsErr = new Error('Cannot start or join call: calls are disabled in this channel.'); export const supportedLocales = []; + +// Local/Session storage keys +export const STORAGE_CALLS_CLIENT_STATS_KEY = 'calls_client_stats'; +export const STORAGE_CALLS_CLIENT_LOGS_KEY = 'calls_client_logs'; +export const STORAGE_CALLS_DEFAULT_AUDIO_INPUT_KEY = 'calls_default_audio_input'; +export const STORAGE_CALLS_DEFAULT_AUDIO_OUTPUT_KEY = 'calls_default_audio_output'; +export const STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY = 'calls_experimental_features'; diff --git a/webapp/src/log.ts b/webapp/src/log.ts index 522d0eb48..cc54190ad 100644 --- a/webapp/src/log.ts +++ b/webapp/src/log.ts @@ -1,20 +1,53 @@ /* eslint-disable no-console */ +import {STORAGE_CALLS_CLIENT_LOGS_KEY} from 'src/constants'; +import {getPersistentStorage} from 'src/utils'; + import {pluginId} from './manifest'; +let clientLogs = ''; + +function appendClientLog(level: string, ...args: unknown[]) { + clientLogs += `${level} [${new Date().toISOString()}] ${args}\n`; +} + +export function persistClientLogs() { + getPersistentStorage().setItem(STORAGE_CALLS_CLIENT_LOGS_KEY, clientLogs); + clientLogs = ''; +} + +export function getClientLogs() { + return getPersistentStorage().getItem(STORAGE_CALLS_CLIENT_LOGS_KEY) || ''; +} + export function logErr(...args: unknown[]) { console.error(`${pluginId}:`, ...args); + try { + if (window.callsClient) { + appendClientLog('error', ...args); + } + } catch (err) { + console.error(err); + } } export function logWarn(...args: unknown[]) { console.warn(`${pluginId}:`, ...args); + if (window.callsClient) { + appendClientLog('warn', ...args); + } } export function logInfo(...args: unknown[]) { console.info(`${pluginId}:`, ...args); + if (window.callsClient) { + appendClientLog('info', ...args); + } } export function logDebug(...args: unknown[]) { - // TODO: convert to debug once we are out of beta. - console.info(`${pluginId}:`, ...args); + console.debug(`${pluginId}:`, ...args); + if (window.callsClient) { + appendClientLog('debug', ...args); + } } diff --git a/webapp/src/slash_commands.tsx b/webapp/src/slash_commands.tsx index f272a6c34..d3774c34b 100644 --- a/webapp/src/slash_commands.tsx +++ b/webapp/src/slash_commands.tsx @@ -12,10 +12,14 @@ import { stopCallRecording, trackEvent, } from 'src/actions'; -import {DisabledCallsErr} from 'src/constants'; +import { + DisabledCallsErr, + STORAGE_CALLS_CLIENT_STATS_KEY, + STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY, +} from 'src/constants'; import * as Telemetry from 'src/types/telemetry'; -import {logDebug} from './log'; +import {getClientLogs, logDebug} from './log'; import { channelHasCall, channelIDForCurrentCall, @@ -24,7 +28,7 @@ import { isRecordingInCurrentCall, } from './selectors'; import {Store} from './types/mattermost-webapp'; -import {getCallsClient, sendDesktopEvent, shouldRenderDesktopWidget} from './utils'; +import {getCallsClient, getPersistentStorage, sendDesktopEvent, shouldRenderDesktopWidget} from './utils'; type joinCallFn = (channelId: string, teamId?: string, title?: string, rootId?: string) => void; @@ -147,11 +151,11 @@ export default async function slashCommandsHandler(store: Store, joinCall: joinC break; } if (fields[2] === 'on') { - window.localStorage.setItem('calls_experimental_features', 'on'); + window.localStorage.setItem(STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY, 'on'); logDebug('experimental features enabled'); } else if (fields[2] === 'off') { logDebug('experimental features disabled'); - window.localStorage.removeItem('calls_experimental_features'); + window.localStorage.removeItem(STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY); } break; case 'stats': { @@ -163,9 +167,12 @@ export default async function slashCommandsHandler(store: Store, joinCall: joinC return {error: {message: err}}; } } - const data = sessionStorage.getItem('calls_client_stats') || '{}'; + const data = getPersistentStorage().getItem(STORAGE_CALLS_CLIENT_STATS_KEY) || '{}'; return {message: `/call stats ${btoa(data)}`, args}; } + case 'logs': { + return {message: `/call logs ${btoa(getClientLogs())}`, args}; + } case 'recording': { if (fields.length < 3 || (fields[2] !== 'start' && fields[2] !== 'stop')) { break; diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index 7e8e0b88f..d89e9b262 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -16,6 +16,7 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {IntlShape} from 'react-intl'; import {parseSemVer} from 'semver-parser'; import CallsClient from 'src/client'; +import {STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY} from 'src/constants'; import RestClient from 'src/rest_client'; import {notificationSounds} from 'src/webapp_globals'; @@ -334,7 +335,7 @@ export function setSDPMaxVideoBW(sdp: string, bandwidth: number) { } export function hasExperimentalFlag() { - return window.localStorage.getItem('calls_experimental_features') === 'on'; + return window.localStorage.getItem(STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY) === 'on'; } export function split(list: T[], i: number, pad = false): [list: T[], overflowed?: T[]] { @@ -602,3 +603,7 @@ export function getWebappUtils() { return utils; } + +export function getPersistentStorage() { + return window.desktop ? localStorage : sessionStorage; +}