github.com/hernad/nomad@v1.6.112/ui/app/components/fs/file.js (about)

     1  /**
     2   * Copyright (c) HashiCorp, Inc.
     3   * SPDX-License-Identifier: MPL-2.0
     4   */
     5  
     6  import Ember from 'ember';
     7  import { inject as service } from '@ember/service';
     8  import Component from '@ember/component';
     9  import { action, computed } from '@ember/object';
    10  import { equal, gt } from '@ember/object/computed';
    11  import RSVP from 'rsvp';
    12  import Log from 'nomad-ui/utils/classes/log';
    13  import timeout from 'nomad-ui/utils/timeout';
    14  import { classNames, attributeBindings } from '@ember-decorators/component';
    15  import classic from 'ember-classic-decorator';
    16  
    17  @classic
    18  @classNames('boxed-section', 'task-log')
    19  @attributeBindings('data-test-file-viewer')
    20  export default class File extends Component {
    21    @service token;
    22    @service system;
    23  
    24    'data-test-file-viewer' = true;
    25  
    26    allocation = null;
    27    taskState = null;
    28    file = null;
    29    stat = null; // { Name, IsDir, Size, FileMode, ModTime, ContentType }
    30  
    31    // When true, request logs from the server agent
    32    useServer = false;
    33  
    34    // When true, logs cannot be fetched from either the client or the server
    35    noConnection = false;
    36  
    37    clientTimeout = 1000;
    38    serverTimeout = 5000;
    39  
    40    mode = 'head';
    41  
    42    @computed('stat.ContentType')
    43    get fileComponent() {
    44      const contentType = this.stat.ContentType || '';
    45  
    46      if (contentType.startsWith('image/')) {
    47        return 'image';
    48      } else if (
    49        contentType.startsWith('text/') ||
    50        contentType.startsWith('application/json')
    51      ) {
    52        return 'stream';
    53      } else {
    54        return 'unknown';
    55      }
    56    }
    57  
    58    @gt('stat.Size', 50000) isLarge;
    59  
    60    @equal('fileComponent', 'unknown') fileTypeIsUnknown;
    61    @equal('fileComponent', 'stream') isStreamable;
    62    isStreaming = false;
    63  
    64    @computed('allocation.id', 'taskState.name', 'file')
    65    get catUrlWithoutRegion() {
    66      const taskUrlPrefix = this.taskState ? `${this.taskState.name}/` : '';
    67      const encodedPath = encodeURIComponent(`${taskUrlPrefix}${this.file}`);
    68      return `/v1/client/fs/cat/${this.allocation.id}?path=${encodedPath}`;
    69    }
    70  
    71    @computed('catUrlWithoutRegion', 'system.{activeRegion,shouldIncludeRegion}')
    72    get catUrl() {
    73      let apiPath = this.catUrlWithoutRegion;
    74      if (this.system.shouldIncludeRegion) {
    75        apiPath += `&region=${this.system.activeRegion}`;
    76      }
    77      return apiPath;
    78    }
    79  
    80    @computed('isLarge', 'mode')
    81    get fetchMode() {
    82      if (this.mode === 'streaming') {
    83        return 'stream';
    84      }
    85  
    86      if (!this.isLarge) {
    87        return 'cat';
    88      } else if (this.mode === 'head' || this.mode === 'tail') {
    89        return 'readat';
    90      }
    91  
    92      return undefined;
    93    }
    94  
    95    @computed('allocation.{id,node.httpAddr}', 'fetchMode', 'useServer')
    96    get fileUrl() {
    97      const address = this.get('allocation.node.httpAddr');
    98      const url = `/v1/client/fs/${this.fetchMode}/${this.allocation.id}`;
    99      return this.useServer ? url : `//${address}${url}`;
   100    }
   101  
   102    @computed('file', 'mode', 'stat.Size', 'taskState.name')
   103    get fileParams() {
   104      // The Log class handles encoding query params
   105      const taskUrlPrefix = this.taskState ? `${this.taskState.name}/` : '';
   106      const path = `${taskUrlPrefix}${this.file}`;
   107  
   108      switch (this.mode) {
   109        case 'head':
   110          return { path, offset: 0, limit: 50000 };
   111        case 'tail':
   112          return { path, offset: this.stat.Size - 50000, limit: 50000 };
   113        case 'streaming':
   114          return { path, offset: 50000, origin: 'end' };
   115        default:
   116          return { path };
   117      }
   118    }
   119  
   120    @computed(
   121      'clientTimeout',
   122      'fileParams',
   123      'fileUrl',
   124      'mode',
   125      'serverTimeout',
   126      'useServer'
   127    )
   128    get logger() {
   129      // The cat and readat APIs are in plainText while the stream API is always encoded.
   130      const plainText = this.mode === 'head' || this.mode === 'tail';
   131  
   132      // If the file request can't settle in one second, the client
   133      // must be unavailable and the server should be used instead
   134      const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
   135      const logFetch = (url) =>
   136        RSVP.race([this.token.authorizedRequest(url), timeout(timing)]).then(
   137          (response) => {
   138            if (!response || !response.ok) {
   139              this.nextErrorState(response);
   140            }
   141            return response;
   142          },
   143          (error) => this.nextErrorState(error)
   144        );
   145  
   146      return Log.create({
   147        logFetch,
   148        plainText,
   149        params: this.fileParams,
   150        url: this.fileUrl,
   151      });
   152    }
   153  
   154    nextErrorState(error) {
   155      if (this.useServer) {
   156        this.set('noConnection', true);
   157      } else {
   158        this.send('failoverToServer');
   159      }
   160      throw error;
   161    }
   162  
   163    @action
   164    toggleStream() {
   165      this.set('mode', 'streaming');
   166      this.toggleProperty('isStreaming');
   167    }
   168  
   169    @action
   170    gotoHead() {
   171      this.set('mode', 'head');
   172      this.set('isStreaming', false);
   173    }
   174  
   175    @action
   176    gotoTail() {
   177      this.set('mode', 'tail');
   178      this.set('isStreaming', false);
   179    }
   180  
   181    @action
   182    failoverToServer() {
   183      this.set('useServer', true);
   184    }
   185  
   186    @action
   187    async downloadFile() {
   188      const timing = this.useServer ? this.serverTimeout : this.clientTimeout;
   189  
   190      try {
   191        const response = await RSVP.race([
   192          this.token.authorizedRequest(this.catUrlWithoutRegion),
   193          timeout(timing),
   194        ]);
   195  
   196        if (!response || !response.ok) throw new Error('file download timeout');
   197  
   198        // Don't download in tests. Unfortunately, since the download is triggered
   199        // by the download attribute of the ephemeral anchor element, there's no
   200        // way to stub this in tests.
   201        if (Ember.testing) return;
   202  
   203        const blob = await response.blob();
   204        const url = window.URL.createObjectURL(blob);
   205        const downloadAnchor = document.createElement('a');
   206  
   207        downloadAnchor.href = url;
   208        downloadAnchor.target = '_blank';
   209        downloadAnchor.rel = 'noopener noreferrer';
   210        downloadAnchor.download = this.file;
   211  
   212        // Appending the element to the DOM is required for Firefox support
   213        document.body.appendChild(downloadAnchor);
   214        downloadAnchor.click();
   215        downloadAnchor.remove();
   216  
   217        window.URL.revokeObjectURL(url);
   218      } catch (err) {
   219        this.nextErrorState(err);
   220      }
   221    }
   222  }