github.com/xxf098/lite-proxy@v0.15.1-0.20230422081941-12c69f323218/web/gui/src/App.vue (about)

     1  <template>
     2    <div>
     3      <el-row>
     4          <el-col :span="22" :offset="1">
     5              <el-card>
     6                          <div slot="header">订阅信息</div>
     7                          <el-container :element-loading-text="loadingContent">
     8                              <el-form label-width="120px" size="large">
     9                                      <el-form-item label="设置:">
    10                                          <el-radio-group v-model="option" :disabled="loading">
    11                                              <el-radio :label="0">基础</el-radio>
    12                                              <el-radio :label="1">高级</el-radio>
    13                                              <el-radio :label="2">导出</el-radio>
    14                                              <el-radio :label="3">手动生成</el-radio>
    15                                          </el-radio-group>
    16                                      </el-form-item>
    17  
    18                                      <el-form-item label="链接:" v-if="option<2">
    19                                          <el-col :span="8">
    20                                              <el-input v-model="subscription" style="width: 800px"
    21                                              @keyup.enter.native="submit" placeholder="支持 V2Ray/Trojan/SS/SSR/Clash 订阅链接,订阅文件及节点链接的批量测速"
    22                                              :disabled="loading||upload" clearable></el-input>
    23                                          </el-col>
    24                                          <el-col :span="12" ></el-col>
    25                                          <el-col :span="12" style="margin-top: 6px;">
    26                                              <el-upload :drag="checkUploadStatus('drag')" :v-if="checkUploadStatus('if')"
    27                                              action="" :show-file-list="false" ref="upload" :http-request="handleFileChange"
    28                                              :auto-upload="true" :before-upload="beforeUpload">
    29                                              <i class="el-icon-upload" v-if="!subscription.length"></i>
    30                                              <!--<el-button slot="trigger" type="primary" icon="el-icon-files" :disabled="loading" v-if="!upload">选择配置文件</el-button>-->
    31                                              <el-button slot="tip" type="danger" icon="el-icon-close" :disabled="loading"
    32                                                  v-if="upload" @click="cancelFileUpload">取消文件选择</el-button>
    33                                              <div class="el-upload__text" v-if="!subscription.length">
    34                                                  还可以将配置文件拖到此处,或<em>点击上传</em></div>
    35                                              </el-upload>
    36                                          </el-col>
    37                                          
    38                                      </el-form-item>
    39  
    40                                      <el-form-item label="并发数:" v-if="option<2">
    41                                          <el-input v-model="concurrency" style="width: 235px" type="number" min="1" max="50"
    42                                              @keyup.enter.native="submit" :disabled="loading"></el-input>
    43                                      </el-form-item>
    44                                      <el-form-item label="测试时长(秒):" v-if="option===1">
    45                                          <el-input v-model="timeout" style="width: 235px" type="number" min="5" max="60"
    46                                              @keyup.enter.native="submit" :disabled="loading"></el-input>
    47                                      </el-form-item>
    48                                      <el-form-item label="去除重复节点:" v-if="option===1">
    49                                          <el-checkbox v-model="unique">去重</el-checkbox>
    50                                      </el-form-item>
    51                                      <el-form-item label="测试项:" v-if="option<2">
    52                                          <el-select v-model="speedtestMode" :disabled="loading">
    53                                              <el-option v-for="(item, key, index) in init.speedtestModes" :key="index"
    54                                                  :label="item" :value="key">
    55                                              </el-option>
    56                                          </el-select>
    57                                      </el-form-item>
    58                                      <el-form-item label="自定义组名:" v-if="option<2">
    59                                          <el-input v-model="groupname" style="width: 235px" 
    60                                              @keyup.enter.native="submit" :disabled="loading" clearable></el-input>
    61                                          <el-button type="primary" @click="submit" style="margin-left: 20px" v-if="!option"
    62                                              :disabled="loading" :loading="loading"><el-icon v-if="!loading" class="el-icon--left"><Select /></el-icon>提 交</el-button>
    63                                          <el-button type="danger" @click="terminate" :icon="Close" v-if="!option"
    64                                              :disabled="!loading"><el-icon class="el-icon--left"><Close /></el-icon>终 止</el-button>
    65                                      </el-form-item>
    66  
    67                                      <el-form-item label="Ping方式:" v-if="option===1" :disabled="loading">
    68                                          <el-select v-model="pingMethod" >
    69                                              <el-option v-for="(item, key, index) in init.pingMethods" :key="index" :label="item" :value="key">
    70                                              </el-option>
    71                                          </el-select>
    72                                          <el-button type="primary" @click="submit" style="margin-left: 20px" :icon="Check" :disabled="loading"
    73                                              :loading="loading">提 交</el-button>
    74                                          <el-button type="danger" @click="terminate" :icon="Close" :disabled="!loading">终 止</el-button>
    75                                      </el-form-item>
    76                                      
    77                                      <!-- export -->
    78                                      <el-form-item label="语言:" v-if="option===2" :disabled="loading">
    79                                          <el-select v-model="language">
    80                                              <el-option key="1" label="EN" value="en"></el-option>
    81                                              <el-option key="2" label="中文" value="cn"></el-option>
    82                                          </el-select>
    83                                      </el-form-item>
    84                                      <el-form-item label="字体大小:" v-if="option===2">
    85                                          <el-input v-model="fontSize" style="width: 235px" type="number" min="12" max="36"
    86                                          @keyup.enter.native="submit" :disabled="loading"></el-input>
    87                                      </el-form-item>
    88                                      <el-form-item label="排序方式:" v-if="option===2" :disabled="loading">
    89                                          <el-select v-model="sortMethod" >
    90                                              <el-option v-for="(item, key, index) in init.sortMethods" :key="index"
    91                                                  :label="item" :value="key">
    92                                              </el-option>
    93                                          </el-select>
    94                                      </el-form-item>
    95                                      <el-form-item label="主题:" v-if="option===2" :disabled="loading">
    96                                          <el-select v-model="theme" >
    97                                              <el-option v-for="(item, key, index) in init.themes" :key="index"
    98                                                  :label="item" :value="key">
    99                                              </el-option>
   100                                          </el-select>
   101                                      </el-form-item>
   102                                      <el-form-item label="结果数据:" v-if="option===3" :disabled="loading">
   103                                          <el-input
   104                                              type="textarea"
   105                                              :autosize="{ minRows: 5, maxRows: 18}"
   106                                              placeholder="input data"
   107                                              style="width: 800px"
   108                                              v-model="generateResultJSON">
   109                                          </el-input>
   110                                          <el-button type="primary" @click="generateResult" style="margin-left: 20px" :icon="Check" :disabled="loading"
   111                                              :loading="loading">生 成</el-button>
   112                                      </el-form-item>
   113                              </el-form>
   114                          </el-container>
   115                      </el-card>
   116  
   117              <br>
   118  
   119                      <el-card>
   120                          <el-row style="display: flex;">
   121                              <el-col style="display: flex;align-items: center;" :span="1">
   122                                  <div>结果</div>
   123                              </el-col>
   124                              <el-col v-if="result.length" :span="8">
   125                                  <el-dropdown trigger="click">
   126                                      <el-button size="large" type="primary">
   127                                          Actions<el-icon class="el-icon--right"><arrow-down /></el-icon>
   128                                      </el-button>
   129                                      <template #dropdown>
   130                                          <el-dropdown-menu slot="dropdown">
   131                                              <el-dropdown-item @click.native="handleCopySub()">复制订阅链接</el-dropdown-item>
   132                                              <el-dropdown-item v-if="!loading && result.length" @click.native="handleCopyAvailable()">复制可用节点</el-dropdown-item>
   133                                              <el-dropdown-item v-if="multipleSelection.length" @click.native="handleCopy()">复制节点</el-dropdown-item>
   134                                              <el-dropdown-item v-if="multipleSelection.length" @click.native="handleSave()">导出节点</el-dropdown-item>
   135                                              <!-- <el-dropdown-item @click.native="handleRetest()">重新测试</el-dropdown-item> -->
   136                                              <el-dropdown-item v-if="multipleSelection.length" @click.native="handleQRCode()">显示二维码</el-dropdown-item>
   137                                              <el-dropdown-item v-if="multipleSelection.length" @click.native="handleExportResult()">导出结果</el-dropdown-item>
   138                                          </el-dropdown-menu>
   139                                      </template>
   140                                  </el-dropdown>
   141                              </el-col>
   142                          </el-row>
   143                          <el-container>
   144                              <!-- https://www.ag-grid.com/vue-data-grid/grid-size/#grid-auto-height  max 300row -->
   145                                  <ag-grid-vue 
   146                                          id="myGrid"
   147                                          style="width: 100%;padding-top: 12px;" 
   148                                          class="ag-theme-alpine"
   149                                          :domLayout="domLayout"
   150                                          :rowData="result"
   151                                          :columnDefs="columns"
   152                                          :getRowId="getRowId"
   153                                          :rowSelection="rowSelection"
   154                                          :suppressRowClickSelection="true"
   155                                          :defaultColDef="defaultColDef"
   156                                          @grid-ready="onGridReady"
   157                                          @selection-changed="onSelectionChanged"
   158                                      >
   159                                  </ag-grid-vue>
   160                          </el-container>
   161                          <!-- <el-container>
   162                              <el-table :data="result" :cell-style="colorCell" ref="result" 
   163                                  :row-key="row => `${row.server}${row.protocol}${row.ping}${row.speed}${row.maxspeed}`"
   164                                  @selection-change="handleSelectionChange" @sort-change="handleSortChange">
   165                                  <el-table-column type="selection" width="55" :selectable="checkSelectable">
   166                                  </el-table-column>
   167                                  <el-table-column label="Remark" align="center" prop="remark" min-width="400" sortable>
   168                                  </el-table-column>
   169                                  <el-table-column label="Server" align="center" prop="server" min-width="160" sortable>
   170                                  </el-table-column>
   171                                  <el-table-column label="Protocol" align="center" prop="protocol" width="120" sortable
   172                                      :filters="[{ text: 'V2Ray', value: 'vmess' }, { text: 'Trojan', value: 'trojan' }, { text: 'ShadowsocksR', value: 'ssr' }, { text: 'Shadowsocks', value: 'ss' }]"
   173                                      :filter-method="filterProtocol">
   174                                  </el-table-column>
   175                                  <el-table-column label="Ping" align="center" prop="ping" width="100" sortable="custom"
   176                                      :filters="[{ text: 'Available ', value: 'available' }]"
   177                                      :filter-method="filterPing">
   178                                  </el-table-column>
   179                                  <el-table-column label="AvgSpeed" align="center" prop="speed" min-width="150" sortable
   180                                      :filters="[{ text: '200KB', value: 204800 }, { text: '500KB', value: 512000 }, { text: '1MB', value: 1048576 }, { text: '2MB', value: 2097152 }, { text: '5MB', value: 5242880 }, { text: '10MB', value: 10485760 },{ text: '15MB', value: 15728640 }, { text: '20MB', value: 20971520 }]"
   181                                      :filter-multiple="false"
   182                                      :filter-method="filterAvgSpeed"
   183                                      :sort-method="speedSort">
   184                                  </el-table-column>
   185                                  <el-table-column label="MaxSpeed" align="center" prop="maxspeed" min-width="150" sortable
   186                                      :filters="[{ text: '200KB', value: 204800 }, { text: '500KB', value: 512000 }, { text: '1MB', value: 1048576 }, { text: '2MB', value: 2097152 }, { text: '5MB', value: 5242880 }, { text: '10MB', value: 10485760 },{ text: '15MB', value: 15728640 }, { text: '20MB', value: 20971520 }]"
   187                                      :filter-multiple="false"
   188                                      :filter-method="filterMaxSpeed"
   189                                      :sort-method="maxSpeedSort">
   190                                  </el-table-column>
   191                              </el-table>
   192                          </el-container> -->
   193                      </el-card>   
   194  
   195              <br>
   196  
   197                          <div :class="['dashboard', dashboardCollapsed ? 'collapsed' : '']">
   198                          <el-card class="progress">
   199                              <div class="progress-bar" :style="{ 'width': testProgress(result, testCount) + '%' }"></div>
   200                              <div class="progress-inner">
   201                                  <div class="progress-item">
   202                                      <span>{{ testProgress(result, testCount) }}%</span>
   203                                      <div>Progress</div>
   204                                  </div>
   205                                  <div v-if="!dashboardCollapsed" class="progress-item">
   206                                      <span>{{ testOkCount }}/{{ result.length }}</span>
   207                                      <div>Ratio</div>
   208                                  </div>
   209                                  <div v-if="!dashboardCollapsed" class="traffic">
   210                                      <span> {{ bytesToSize(totalTraffic) }} </span>
   211                                      <div>Traffic</div>
   212                                  </div>
   213                                  <div v-if="!dashboardCollapsed" class="time">
   214                                      <span>{{ formatSeconds(totalTime) }}</span>
   215                                      <div>Time</div>
   216                                  </div>
   217                              </div>
   218                          </el-card>
   219                          <el-card class="category" v-memo="[result]">
   220                              <ul>
   221                                  <li v-if="dashboardCollapsed">
   222                                      <span>{{ result.filter(item => item.ping > 0).length }}/{{ result.length }}</span>
   223                                      <div>Ratio</div>
   224                                  </li>
   225                                  <li v-if="!dashboardCollapsed">
   226                                      <span>{{ result.filter(item => item.protocol.startsWith("vmess")).length }}</span>
   227                                      <div>Vmess</div>
   228                                  </li>
   229                                  <li v-if="!dashboardCollapsed">
   230                                      <span>{{ result.filter(item => item.protocol === "trojan").length }}</span>
   231                                      <div>Trojan</div>
   232                                  </li>
   233                                  <li v-if="!dashboardCollapsed">
   234                                      <span>{{ result.filter(item => item.protocol === "ssr").length }}</span>
   235                                      <div>SSR</div>
   236                                  </li>
   237                                  <li v-if="!dashboardCollapsed">
   238                                      <span>{{ result.filter(item => item.protocol === "ss").length }}</span>
   239                                      <div>SS</div>
   240                                  </li>
   241                              </ul>
   242                          </el-card>
   243                          <div class="icon" @click="handleDashboardCollapsed()">
   244                              <el-icon v-if="!dashboardCollapsed"><Right /></el-icon>
   245                              <el-icon v-if="dashboardCollapsed"><Back /></el-icon>
   246                              <!-- <i v-if="!dashboardCollapsed" class="el-icon-right" ></i>
   247                              <i v-if="dashboardCollapsed" class="el-icon-back"></i> -->
   248                          </div>
   249                      </div>
   250  
   251              <br>
   252  
   253              <el-card v-if="picdata.length">
   254                  <div slot="header">导出图片</div>
   255                  <el-container>
   256                      <el-image :src="picdata" fit="true" placeholder="未加载图片" id="result_png"></el-image>
   257                  </el-container>
   258              </el-card>
   259          </el-col>
   260      </el-row>
   261      <el-dialog title="Share Links with QRcode" center v-model="qrCodeDialogVisible" width="40%"
   262          @opened="handleQRCodeCreate" :before-close="qrCodeHandleClose">
   263          <el-scrollbar style="height:360px;">
   264              <el-row>
   265                  <el-col v-for="(item, index) of multipleSelection" :key="index" :span="12">
   266                      <el-card :body-style="{ padding: '0px', height:'400px'}" shadow="hover"
   267                          style="width: 320px;height: 330px;text-align: center;">
   268                          <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; margin-top: 15px;">
   269                              <div :id="'qrcode_' + item.id" style="margin-left: 20px;"></div>
   270                              <div class="truncate_remark">{{ item.remark }}</div>
   271                              <div>{{ `${item.ping}ms ${item.speed} ${item.maxspeed}` }}</div>
   272                          </div>
   273                      </el-card>
   274                  </el-col>
   275              </el-row>
   276          </el-scrollbar>
   277      </el-dialog>
   278    </div>
   279  </template>
   280  
   281  <script>
   282  
   283  import "ag-grid-community/dist/styles/ag-grid.css";
   284  import "ag-grid-community/dist/styles/ag-theme-alpine.css";
   285  import { AgGridVue } from 'ag-grid-vue3';
   286  
   287  const go = new Go();
   288      WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
   289        go.run(result.instance);
   290  });
   291  
   292  let themes = {
   293      "original": {
   294          colorgroup: [
   295              [255, 255, 255],
   296              [128, 255, 0],
   297              [255, 255, 0],
   298              [255, 128, 192],
   299              [255, 0, 0]
   300          ],
   301          bounds: [0, 64 * 1024, 512 * 1024, 4 * 1024 * 1024, 16 * 1024 * 1024],
   302      },
   303      "rainbow": {
   304          colorgroup: [
   305                  [255, 255, 255],
   306                  [102, 255, 102],
   307                  [255, 255, 102],
   308                  [255, 178, 102],
   309                  [255, 102, 102],
   310                  [226, 140, 255],
   311                  [102, 204, 255],
   312                  [102, 102, 255]
   313              ],
   314              bounds: [0, 64 * 1024, 512 * 1024, 4 * 1024 * 1024, 16 * 1024 * 1024, 24 * 1024 * 1024, 32 * 1024 * 1024, 40 * 1024 * 1024 ]
   315      } 
   316  }
   317  
   318  let ws = null
   319  
   320  export default {
   321      data() {
   322          return {
   323              upload: false,
   324              filecontent: "",
   325              loading: false,
   326              subscription: "",
   327              concurrency: 2,
   328              timeout: 15,
   329              unique: true,
   330              groupname: "",
   331              loadingContent: "",
   332              speedtestMode: "all",
   333              pingMethod: "googleping",
   334              sortMethod: "rspeed",
   335              exportMaxSpeed: true,
   336              method: "SOCKET",
   337              picdata: "",
   338              option: 0,
   339              multipleSelection: [],
   340              qrCodeDialogVisible: false,
   341              totalTraffic: 0,
   342              totalTime: 0,
   343              language: "en",
   344              fontSize: 24,
   345              theme: "rainbow",
   346              generateResultJSON: "",
   347              dashboardCollapsed: true,
   348              testCount: 0,
   349              testOkCount: 0,
   350              sortState: {},
   351              // agGrid
   352              columns: this.columns,
   353              gridApi: null,
   354              getRowId: null,
   355              domLayout: null,
   356              rowSelection: null,
   357              defaultColDef: {
   358                  resizable: true,
   359                  sortable: true,
   360                  cellStyle: { textAlign: 'center' },
   361              },
   362  
   363              init: {
   364                  speedtestModes: {
   365                      all: "全部",
   366                      pingonly: "Ping only",
   367                      speedonly: "Speed only",
   368                  },
   369                  pingMethods: {
   370                      googleping: "Google",
   371                      tcping: "TCP",
   372                  },
   373                  sortMethods: {
   374                      rspeed: "speed 倒序",
   375                      speed: "speed 顺序",
   376                      ping: "ping 顺序",
   377                      rping: "ping 倒序",
   378                      none: "默认",
   379                  },
   380                  themes: {
   381                      rainbow: "Rainbow",
   382                      original: "Original",
   383                  }
   384              },
   385              result: []
   386          }
   387      },
   388      components: {
   389          'ag-grid-vue': AgGridVue
   390      },
   391      created() {        
   392          this.columns = Object.freeze([
   393                  { headerName: 'Remark', field: 'remark', headerCheckboxSelection: true,checkboxSelection: true, minWidth: 500, flex: 1, filter: 'agTextColumnFilter', filterParams: {suppressAndOrCondition: true} },
   394                  { headerName: 'Server', field: 'server', minWidth: 330, filter: 'agTextColumnFilter', filterParams: {suppressAndOrCondition: true} },
   395                  { headerName: "Protocol", field: 'protocol', width: 150, filter: 'agTextColumnFilter' },
   396                  { headerName: 'Ping(ms)', field: 'ping', width: 200, sortingOrder: ['desc', 'asc', null], comparator: (valueA, valueB, nodeA, nodeB, isInverted) => {
   397                      // isInverted: true for Ascending, false for Descending.
   398                      if (isInverted) {
   399                          let ping1 = parseFloat(valueB);
   400                          if (ping1 < 1) { ping1 = 99999 }
   401                          let ping2 = parseFloat(valueA);
   402                          if (ping2 < 1) { ping2 = 99999 }
   403                          return ping1 - ping2
   404                      } else  {
   405                          return parseFloat(valueB) - parseFloat(valueA)
   406                      } 
   407                  }, filter: 'agNumberColumnFilter', filterParams: { 
   408                      suppressAndOrCondition: true,
   409                      filterOptions: [
   410                          { displayKey: "lessThanOrEqual", 
   411                          displayName: "<=", 
   412                          predicate: ([filterValue], cellValue) =>  cellValue > 0 && cellValue <= filterValue }
   413                      ] }},
   414                  { headerName: 'AvgSpeed', field: 'speed', width: 200, cellStyle: this.speedCellStyle, sortingOrder: ['asc', null], comparator: (valueA, valueB, nodeA, nodeB, isInverted) => {
   415                          const speed1 = isNaN(this.getSpeed(valueA)) ? -1 : this.getSpeed(valueA);
   416                          const speed2 = isNaN(this.getSpeed(valueB)) ? -1 : this.getSpeed(valueB);
   417                          return speed2 - speed1
   418                  }},
   419                  { headerName: 'MaxSpeed', field: 'maxspeed', width: 200, cellStyle: this.speedCellStyle,sortingOrder: ['asc', null], comparator: (valueA, valueB, nodeA, nodeB, isInverted) => {
   420                          const speed1 = isNaN(this.getSpeed(valueA)) ? -1 : this.getSpeed(valueA);
   421                          const speed2 = isNaN(this.getSpeed(valueB)) ? -1 : this.getSpeed(valueB);
   422                          return speed2 - speed1
   423                  }},
   424              ])
   425          this.getRowId = params => params.data.id;
   426          this.rowSelection = 'multiple';     
   427          this.domLayout = 'autoHeight';
   428      },
   429      methods: {
   430          updateRow(id, newData) {
   431              const rowNode = this.gridApi.getRowNode(id);
   432              if (!rowNode) {
   433                  this.gridApi.applyTransaction({ add: [newData] })
   434              } else {
   435                  this.gridApi.applyTransaction({ update: [newData] })
   436              }
   437          },
   438          updateRowAsync(id, newData) {
   439              const rowNode = this.gridApi.getRowNode(id);
   440              if (!rowNode) {
   441                  this.gridApi.applyTransactionAsync({ add: [newData] })
   442              } else {
   443                  this.gridApi.applyTransactionAsync({ update: [newData] })
   444              }
   445          },
   446          setAutoHeight() {
   447              this.gridApi.setDomLayout('autoHeight');
   448              // auto height will get the grid to fill the height of the contents,
   449              // so the grid div should have no height set, the height is dynamic.
   450              document.querySelector('#myGrid').style.height = '';
   451          },
   452          setFixedHeight() {
   453              // we could also call setDomLayout() here as normal is the default
   454              this.gridApi.setDomLayout('normal');
   455              // when auto height is off, the grid ahs a fixed height, and then the grid
   456              // will provide scrollbars if the data does not fit into it.
   457              document.querySelector('#myGrid').style.height = '3000px';
   458          },
   459          speedCellStyle(params) {
   460              // console.log(`params.value: ${params.value}`)
   461              const style = {textAlign: 'center'}
   462              const speed = this.getSpeed(params.value);
   463              if (speed < 1 || isNaN(parseFloat(speed))) return style;
   464              const color = this.getSpeedColor(speed);
   465              style.backgroundColor = color;
   466              return { backgroundColor: color, textAlign: 'center' }
   467          },
   468          onGridReady(params) {
   469              this.gridApi = params.api;
   470              // this.gridColumnApi = params.columnApi;
   471          },
   472          onSelectionChanged() {
   473              const selectedRows = this.gridApi.getSelectedRows();
   474              this.multipleSelection = selectedRows;
   475          },
   476          bytesToSize: function (bytes) {
   477              const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
   478              if (!bytes || bytes === 0) return '0 B';
   479              const i = parseInt(Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)), 10);
   480              if (i === 0) return `${bytes} ${sizes[i]})`;
   481              return `${(bytes / (1024 ** i)).toFixed(1)} ${sizes[i]}`;
   482          },
   483          testProgress: function (result, testCount) {
   484              return result.length ? Math.floor(testCount/result.length*100) : 0
   485          },
   486          formatSeconds: function (seconds) {
   487              let totalTime = seconds > 0 ? seconds : 0
   488              const hours = Math.floor(totalTime / 3600);
   489              totalTime %= 3600;
   490              const minutes = Math.floor(totalTime / 60);
   491              const secs = totalTime % 60;
   492              let result = `${secs}s`
   493              result = minutes > 0 ? `${minutes}m ${result}` : result
   494              result = hours > 0 ? `${hours}h ${result}` : result
   495              return result
   496          },
   497          incrTotalTime: function () {
   498              if (this.totalTime >= 0 && this.loading) {
   499                  this.$nextTick(() => {
   500                      setTimeout(() => {
   501                          this.totalTime++;
   502                          this.incrTotalTime()
   503                      }, 1000);
   504                  })
   505              }
   506          },
   507          cancelFileUpload: function () {
   508              let self = this;
   509              this.file = null;
   510              this.filecontent = '';
   511              this.subscription = '';
   512              self.upload = false;
   513          },
   514          handleFileChange(e) {
   515              let self = this;
   516              this.file = e.file;
   517              this.errText = '';
   518              if (!this.file || !window.FileReader) return;
   519              let reader = new FileReader();
   520              reader.readAsText(this.file);
   521              reader.onloadend = function () {
   522                  self.filecontent = this.result;
   523                  self.subscription = self.file.name;
   524                  self.upload = true;
   525              }
   526          },
   527          beforeUpload(file) {
   528              // const isType = file.type === 'application/json' || file.type === 'application/octet-stream'
   529              const fsize = file.size / 1024 / 1024 <= 10;
   530              // if (!isType) {
   531              // 	this.$message.error('选择的文件格式有误!');
   532              // }
   533              if (!fsize) {
   534                  this.$message.error('上传的文件不能超过10MB!');
   535              }
   536              return fsize;
   537          },
   538          checkUploadStatus(type) {
   539              if (!this.upload) {
   540                  if (this.subscription.length)
   541                      return false;
   542                  else
   543                      return true;
   544              }
   545              else {
   546                  if (type === "if")
   547                      return true;
   548                  else if (type === "drag")
   549                      return false;
   550              }
   551          },
   552          submit: function () {
   553              this.testCount = 0;
   554              this.testOkCount = 0;
   555              if (!this.subscription.length) {
   556                  this.$alert("请先输入链接或选择文件!", "错误", {
   557                      type: "error",
   558                  });
   559              } else {
   560                  // this.$refs.result.clearSelection();
   561                  // this.$refs.result.clearFilter();
   562                  // this.$refs.result.clearSort();
   563                  this.setAutoHeight()
   564                  this.loading = true;
   565                  this.totalTraffic = 0;
   566                  this.totalTime = 0;
   567                  this.picdata = "";
   568                  this.result = [];
   569                  this.incrTotalTime()
   570                  this.loadingContent = "等待后端响应……";
   571                  this.starttest();
   572              }
   573          },
   574          generateResult: function (params) {
   575              if (!this.generateResultJSON) {
   576                  return
   577              }
   578              const requestOptions = {
   579                  method: "POST",
   580                  headers: { "Content-Type": "application/json" },
   581                  body: this.generateResultJSON
   582              };
   583              const url = `${window.location.protocol}//${window.location.host}/generateResult`
   584              fetch(url, requestOptions)
   585                  .then(resp => resp.text())
   586                  .then(data => {
   587                      this.picdata = data
   588                  })
   589          },
   590          terminate: function () {
   591              this.loading = false;
   592              this.loadingContent = "等待后端响应……";
   593              this.result = [];
   594              this.disconnect();
   595          },
   596          handleSelectionChange(val) {
   597              // console.log(`select: ${JSON.stringify(val)}`)
   598              this.multipleSelection = val;
   599          },
   600          handleSortChange(val) {
   601              if (val.prop === "ping") { 
   602                  if (val.order === "ascending") {
   603                      this.result.sort((obj1, obj2) => {
   604                          let ping1 = parseFloat(obj1.ping);
   605                          if (ping1 < 1) { ping1 = 99999 }
   606                          let ping2 = parseFloat(obj2.ping);
   607                          if (ping2 < 1) { ping2 = 99999 }
   608                          return ping1 - ping2
   609                      })
   610                  } else if (val.order === "descending") {
   611                      this.result.sort((obj1, obj2) => parseFloat(obj2.ping) - parseFloat(obj1.ping))
   612                  } else {
   613                      this.result.sort((obj1, obj2) => obj1.id - obj2.id)
   614                  }
   615               }
   616          },
   617          copyToClipboard: async function (data) {
   618              if (navigator.clipboard) {
   619                      await navigator.clipboard.writeText(data)
   620                  } else {
   621                      let textArea = document.createElement("textarea");
   622                      textArea.value = data;
   623                      // make the textarea out of viewport
   624                      textArea.style.position = "fixed";
   625                      textArea.style.left = "-999999px";
   626                      textArea.style.top = "-999999px";
   627                      document.body.appendChild(textArea);
   628                      textArea.focus();
   629                      textArea.select();
   630                      document.execCommand('copy');
   631                      textArea.remove();
   632                  }
   633          },
   634          handleCopySub: async function () {
   635              // url
   636              if (this.subscription.trim().startsWith("http") && !this.subscription.trim().endsWith(".yaml") && !this.subscription.trim().endsWith(".yml")) {
   637                  await navigator.clipboard.writeText(this.subscription.trim())
   638                  this.$message.success("Copy Subscription succeed!");
   639                  return
   640              }
   641              const host = window.location.host;
   642              if (host.startsWith("127.0.0.1")) {
   643                  const url = `${window.location.protocol}//${window.location.host}/getSubscriptionLink`
   644                  const groupname = this.groupname.trim() || "Default"
   645                  const requestOptions = {
   646                      method: "POST",
   647                      headers: { "Content-Type": "application/json" },
   648                      body: JSON.stringify({"filePath": this.subscription.trim(), "group": groupname})
   649                  };
   650                  fetch(url, requestOptions)
   651                      .then(resp => resp.text())
   652                      .then(data => {
   653                          navigator.clipboard.writeText(data)
   654                          this.$message.success("Copy Subscription succeed!");
   655                      })
   656                  return
   657              }
   658              this.$message.error("Copy Subscription failed!");
   659          },
   660          handleCopy: async function () {
   661              try {
   662                  const links = this.multipleSelection.map(elem => elem.link).join("\n")
   663                  await this.copyToClipboard(links)
   664                  this.$message.success("Copy link succeed!");
   665              } catch (err) {
   666                  this.$message.error("Copy link failed!");
   667              }
   668          },
   669          handleCopyAvailable: async function () {
   670              try {
   671                  const links = this.result.filter(elem => elem.ping > 0).map(elem => elem.link)
   672                  await this.copyToClipboard(links.join("\n"))
   673                  this.$message.success(`Copy ${links.length} link${links.length>1 ? "s" : ""} succeed!`);
   674              } catch (err) {
   675                  this.$message.error(`Copy link failed!`);
   676              }
   677          },
   678          qrCodeHandleClose() {
   679              this.qrCodeDialogVisible = false;
   680              this.multipleSelection.forEach(item => {
   681                  document.getElementById('qrcode_' + item.id).innerHTML = '';
   682              });
   683          },
   684          handleQRCode() {
   685              this.qrCodeDialogVisible = true
   686          },
   687          handleQRCodeCreate: function () {
   688              this.$nextTick(() => {
   689                  const items = this.multipleSelection.map(item => {
   690                      return {
   691                          gid: 'qrcode_' + item.id,
   692                          link: item.link,
   693                          size: 260
   694                      }
   695                  })
   696                  wasmQRcode(JSON.stringify(items))
   697                  // this.multipleSelection.forEach(item => {
   698                  // 	const gid = 'qrcode_' + item.id;
   699                  // 	wasmQRcode(gid, item.link, 260, 260)
   700                  // })
   701              })
   702          },
   703          handleRetest: function () {
   704              // const data = { testid: id, testMode: 3, links: [link], ...this.getJSONOptions() }
   705              const testids = this.multipleSelection.map(elem => elem.id)
   706              const links = this.multipleSelection.map(elem => elem.link)
   707              const data = { testMode: 3, ...this.getJSONOptions(), testids, links }
   708              // this.$refs.result.clearSelection();
   709              // this.$refs.result.clearFilter();
   710              // this.$refs.result.clearSort();
   711              console.log(`handleRetest: ${JSON.stringify(data)}`)
   712              this.send(JSON.stringify(data));
   713          },
   714          saveData: function (data, name) {
   715              const blob = new Blob([data], { type: 'text/plain;charset=utf-8;' })
   716              const link = document.createElement('a')
   717              if (link == null || link.download == null || link.download == undefined) {
   718                  return
   719              }
   720              var event = new Date();
   721              event.setMinutes(event.getMinutes() - event.getTimezoneOffset());
   722              let jsonDate = event.toJSON().slice(0, 19);
   723              jsonDate = jsonDate.replaceAll("-", "")
   724              jsonDate = jsonDate.replaceAll("T", "")
   725              jsonDate = jsonDate.replaceAll(":", "")
   726              let url = URL.createObjectURL(blob)
   727              link.setAttribute('href', url)
   728              link.setAttribute('download', `${name}_${jsonDate}`)
   729              link.style.visibility = 'hidden'
   730              document.body.appendChild(link)
   731              link.click()
   732              document.body.removeChild(link)
   733              
   734          },
   735          handleDashboardCollapsed: function () {
   736              console.log(this.dashboardCollapsed)
   737              this.dashboardCollapsed = !this.dashboardCollapsed
   738          },
   739          handleSave: function () {
   740              const links = this.multipleSelection.map(elem => {
   741                      return `# ${elem.remark}\t${elem.ping}\t${elem.speed}\t${elem.maxspeed}\n${elem.link}`
   742              })
   743              if (this.subscription.match(/^https?:\/\//g)) {
   744                  links.unshift(`# ${this.subscription}`)
   745              }
   746              this.saveData(links.join("\n"), "profile")
   747          },
   748          handleExportResult: function (params) {
   749              const nodes = this.result.map(item => {
   750                  const avg_speed = Math.floor(this.getSpeed(item.speed)) || 0
   751                  const max_speed = Math.floor(this.getSpeed(item.maxspeed)) || 0
   752                  return {
   753                      id: item.id,
   754                      group: item.group,
   755                      remarks: item.remark,
   756                      protocol: item.protocol,
   757                      ping: `${item.ping}`,
   758                      avg_speed,
   759                      max_speed,
   760                      isok: item.ping > 0,
   761                  }
   762              })
   763              const data = {
   764                  totalTraffic: this.bytesToSize(this.totalTraffic),
   765                  totalTime: this.formatSeconds(this.totalTime),
   766                  language: this.language,
   767                  fontSize: this.fontSize,
   768                  theme: this.theme,
   769                  sortMethod: this.sortMethod,
   770                  nodes,
   771              }
   772              this.saveData(JSON.stringify(data, null, 2), "result")
   773          },
   774          colorCell: function ({
   775              row,
   776              column,
   777              rowIndex,
   778              columnIndex
   779          }) {
   780              let style = {color: "black", "font-weight": 600};
   781              let speed = 0;
   782              switch (columnIndex) {
   783                  case 5:
   784                      speed = this.getSpeed(row.speed);
   785                      break;
   786                  case 6:
   787                      speed = this.getSpeed(row.maxspeed);
   788                      break;
   789                  default:
   790                      return style;
   791              }
   792              if (isNaN(parseFloat(speed))) return style;
   793              let color = this.getSpeedColor(speed);
   794              // console.log(`speed: ${speed}, row.speed: ${row.speed}, row.maxspeed: ${row.maxspeed}  color: ${color}`);
   795              style.background = color
   796              return style;
   797          },
   798          // useNewPalette() {
   799          // 	colorgroup = [
   800          // 		[255, 255, 255],
   801          // 		[102, 255, 102],
   802          // 		[255, 255, 102],
   803          // 		[255, 178, 102],
   804          // 		[255, 102, 102],
   805          // 		[226, 140, 255],
   806          // 		[102, 204, 255],
   807          // 		[102, 102, 255]
   808          // 	];
   809          // 	bounds = [
   810          // 		0,
   811          // 		64 * 1024,
   812          // 		512 * 1024,
   813          // 		4 * 1024 * 1024,
   814          // 		16 * 1024 * 1024,
   815          // 		24 * 1024 * 1024,
   816          // 		32 * 1024 * 1024,
   817          // 		40 * 1024 * 1024
   818          // 	];
   819          // },
   820          getSpeed(speed) {
   821              let value = parseFloat(speed.toString().slice(0, -2));
   822              if (speed.toString().slice(-2) == "MB") {
   823                  value *= 1048576;
   824              } else if (speed.toString().slice(-2) == "KB") {
   825                  value *= 1024;
   826              } else value = parseFloat(speed.toString().slice(0, -1));
   827              return value;
   828          },
   829          getColor(lc, rc, level) {
   830              let colors = [];
   831              let r, g, b;
   832              colors.push(parseInt(lc[0] * (1 - level) + rc[0] * level));
   833              colors.push(parseInt(lc[1] * (1 - level) + rc[1] * level));
   834              colors.push(parseInt(lc[2] * (1 - level) + rc[2] * level));
   835              return colors;
   836          },
   837          getSpeedColor(speed) {
   838              const {colorgroup, bounds} = themes[this.theme];
   839              for (let i = 0; i < bounds.length - 1; i++) {
   840                  if (speed >= bounds[i] && speed <= bounds[i + 1]) {
   841                      let color = this.getColor(
   842                          colorgroup[i],
   843                          colorgroup[i + 1],
   844                          (speed - bounds[i]) / (bounds[i + 1] - bounds[i])
   845                      );
   846                      return "rgb(" + color[0] + "," + color[1] + "," + color[2] + ")";
   847                  }
   848              }
   849              return (
   850                  "rgb(" +
   851                  colorgroup[colorgroup.length - 1][0] +
   852                  "," +
   853                  colorgroup[colorgroup.length - 1][1] +
   854                  "," +
   855                  colorgroup[colorgroup.length - 1][2] +
   856                  ")"
   857              );
   858          },
   859          connect(url) {
   860              try {
   861                  ws = new WebSocket(url);
   862              } catch (ex) {
   863                  this.loading = false;
   864                  //this.$message.error('Cannot connect: ' + ex)
   865                  this.$alert("后端连接错误!请检查后端运行情况!原因:" + ex, "错误");
   866                  return;
   867              }
   868          },
   869          disconnect() {
   870              if (ws) {
   871                  ws.close();
   872              }
   873          },
   874          send(msg) {
   875              if (ws) {
   876                  try {
   877                      ws.send(msg);
   878                  } catch (ex) {
   879                      this.$message.error("Cannot send: " + ex);
   880                  }
   881              } else {
   882                  this.loading = false;
   883                  //this.$message.error('Cannot send: Not connected')
   884                  this.$alert("后端连接错误!请检查后端运行情况!", "错误");
   885              }
   886          },
   887          getJSONOptions() {
   888              let self = this;
   889              let groupstr = self.groupname == "" ? "?empty?" : self.groupname;
   890              // const options = `^${groupstr}^${self.speedtestMode}^${self.pingMethod}^${self.sortMethod}^${self.exportMaxSpeed}^${self.concurrency}^${self.timeout}`
   891              return {
   892                  group: groupstr,
   893                  speedtestMode: self.speedtestMode,
   894                  pingMethod: self.pingMethod,
   895                  sortMethod: self.sortMethod,
   896                  unique: self.unique,
   897                  concurrency: parseInt(self.concurrency),
   898                  timeout: parseInt(self.timeout),
   899                  language: self.language,
   900                  fontSize: parseInt(self.fontSize),
   901                  theme: self.theme,
   902              }
   903          },
   904          getOptions() {
   905              let self = this;
   906              let groupstr = self.groupname == "" ? "?empty?" : self.groupname;
   907              const options = `^${groupstr}^${self.speedtestMode}^${self.pingMethod}^${self.sortMethod}^${self.exportMaxSpeed}^${self.concurrency}^${self.timeout}`
   908              return options
   909          },
   910          starttest() {
   911              let self = this;
   912              let groupstr = self.groupname == "" ? "?empty?" : self.groupname;
   913              this.result = [];
   914              this.connect(`ws://${window.location.host}/test`);
   915              if (ws) {
   916                  ws.addEventListener("open", function (ev) {
   917                      const data = self.getJSONOptions()
   918                      data.testMode = 2
   919                      data.subscription = self.upload ? self.filecontent : self.subscription;
   920                      this.send(JSON.stringify(data));
   921                  });
   922                  ws.addEventListener("message", this.MessageEvent);
   923              } else {
   924                  this.loading = false;
   925                  this.$alert("后端连接错误!请检查后端运行情况!", "错误");
   926              }
   927          },
   928          loopevent(id, tester) {
   929              const item = this.result[id];
   930              switch (tester) {
   931                  case "ping":
   932                      item.ping = "测试中...";
   933                      item.loss = "测试中...";
   934                      item.testing = true
   935                      this.result[id]=item;
   936                      this.updateRow(id, item);
   937                      break;
   938                  case "speed":
   939                      item.speed = "测试中...";
   940                      item.maxspeed = "测试中...";
   941                      item.testing = true
   942                      this.result[id]=item;
   943                      this.updateRow(id, item);
   944                      break;
   945              }
   946          },
   947          MessageEvent(ev) {
   948              console.log(ev.data);
   949              let json = JSON.parse(ev.data);
   950              let id = parseInt(json.id);
   951  
   952              let item = {};
   953              switch (json.info) {
   954                  case "started":
   955                      this.loadingContent = "后端已启动……";
   956                      break;
   957                  case "fetchingsub":
   958                      this.loadingContent = "正在获取节点,若节点较多将需要一些时间……";
   959                      break;
   960                  case "begintest":
   961                      this.loadingContent = "疯狂测速中……";
   962                      break;
   963                  case "gotserver":
   964                      item = {
   965                          id: id,
   966                          group: this.groupname == "" ? json.group : this.groupname,
   967                          remark: json.remarks,
   968                          server: json.server,
   969                          protocol: json.protocol,
   970                          link: json.link,
   971                          loss: "0.00%",
   972                          ping: "0.00",
   973                          speed: "0.00B",
   974                          maxspeed: "0.00B"
   975                      };
   976                      this.result[id] = item;
   977                      this.updateRow(id, item);
   978                      break;
   979                  case "gotservers":
   980                      const items = json.servers.map(json => {
   981                          item = {
   982                              id: json.id,
   983                              group: this.groupname == "" ? json.group : this.groupname,
   984                              remark: json.remarks,
   985                              server: json.server,
   986                              protocol: json.protocol,
   987                              link: json.link,
   988                              loss: "0.00%",
   989                              ping: "0.00",
   990                              speed: "0.00B",
   991                              maxspeed: "0.00B"
   992                          };
   993                          this.result[json.id] = item;
   994                          return item
   995                      });
   996                      this.gridApi.applyTransaction({ add: items })
   997                      if (this.domLayout === "autoHeight" && this.result.length > 150) {
   998                          this.setFixedHeight()
   999                          this.domLayout = "normal"
  1000                      } 
  1001                      break;							
  1002                  case "endone":
  1003                      item = this.result[id];
  1004                      item.testing = false
  1005                      this.result[id] = item;
  1006                      this.updateRow(id, item);
  1007                      break;
  1008                  case "startping":
  1009                      //inverval=setInterval("app.loopevent("+id+",\"ping\")",300)
  1010                      this.loopevent(id, "ping");
  1011                      break;
  1012                  case "gotping":
  1013                      //clearInterval(interval)
  1014                      item = this.result[id];
  1015                      // item.loss = json.loss;
  1016                      item.ping = json.ping || 0;
  1017                      this.testCount += 1
  1018                      if (item.ping > 0) {
  1019                          this.testOkCount += 1
  1020                      }
  1021                      /*
  1022                                  item = {
  1023                                      "group": json.group,
  1024                                      "remark": json.remarks,
  1025                                      "loss": json.loss,
  1026                                      "ping": json.ping,
  1027                                      "speed": "0.00KB"
  1028                                  }
  1029                                  */
  1030                      this.result[id] = item;
  1031                      this.updateRowAsync(id, item);
  1032                      break;
  1033                  case "startspeed":
  1034                      //inverval=setInterval("app.loopevent("+id+",\"speed\")",300)
  1035                      this.loopevent(id, "speed");
  1036                      break;
  1037                  case "gotspeed":
  1038                      //clearInterval(interval)
  1039                      item = this.result[id];
  1040                      item.speed = json.speed;
  1041                      item.maxspeed = json.maxspeed;
  1042                      this.totalTraffic += json.traffic
  1043                      this.result[id] = item;
  1044                      this.updateRowAsync(id, item);
  1045                      break;
  1046                  case "picsaving":
  1047                      this.$notify.info("保存结果图片中……");
  1048                      break;
  1049                  case "picsaved":
  1050                      this.$notify.success("图片已保存!路径:" + json.path);
  1051                      break;
  1052                  case "picdata":
  1053                      this.picdata = json.data;
  1054                      break;
  1055                  case "eof":
  1056                      this.loading = false;
  1057                      this.$notify.success(`${this.result.length}个节点测试完成`);
  1058                      break;
  1059                  case "retest":
  1060                      item = this.result[id];
  1061                      this.$notify.error(
  1062                          "节点 " + item.group + " - " + item.remark + " 第一次测试无速度,将重新测试。"
  1063                      );
  1064                      break;
  1065                  case "nospeed":
  1066                      item = this.result[id];
  1067                      this.$notify.error(
  1068                          "节点 " + item.group + " - " + item.remark + " 第二次测试无速度。"
  1069                      );
  1070                      item.speed = "0.00B";
  1071                      item.maxspeed = "0.00B";
  1072                      this.result[id] = item;
  1073                      this.updateRow(id, item);
  1074                      break;
  1075                  case "error":
  1076                      switch (json.reason) {
  1077                          case "noconnection":
  1078                              item = this.result[id];
  1079                              item.ping = "0.00";
  1080                              item.loss = "100.00%";
  1081                              this.$notify.error(
  1082                                  "节点 " + item.group + " - " + item.remark + " 无法连接。"
  1083                              );
  1084                              this.result[id] = item;
  1085                              this.updateRow(id, item);
  1086                              break;
  1087                          case "noresolve":
  1088                              item = this.result[id];
  1089                              item.ping = "0.00";
  1090                              item.loss = "100.00%";
  1091                              this.$notify.error(
  1092                                  "节点 " + item.group + " - " + item.remark + " 无法解析到 IP 地址。"
  1093                              );
  1094                              this.result[id] = item;
  1095                              this.updateRow(id, item);
  1096                              break;
  1097                          case "nonodes":
  1098                              this.$alert("找不到任何节点。请检查订阅链接。", "错误");
  1099                              break;
  1100                          case "invalidsub":
  1101                              this.$alert("订阅获取异常。请检查订阅链接。", "错误");
  1102                              this.terminate()
  1103                              break;
  1104                          case "norecoglink":
  1105                              this.$alert("找不到任何链接。请检查提供的链接格式。", "错误");
  1106                              break;
  1107                          case "unhandled":
  1108                              this.$alert("程序异常退出!", "错误");
  1109                              break;
  1110                      }
  1111                      console.log("error:" + json.reason);
  1112                      break;
  1113              }
  1114          },
  1115          floatSort: function (obj1, obj2) {
  1116              return parseFloat(obj1.ping) - parseFloat(obj2.ping);
  1117          },
  1118          speedSort: function (obj1, obj2) {
  1119              const speed1 = isNaN(this.getSpeed(obj1.speed)) ? -1 : this.getSpeed(obj1.speed);
  1120              const speed2 = isNaN(this.getSpeed(obj2.speed)) ? -1 : this.getSpeed(obj2.speed);
  1121              return speed1 - speed2;
  1122          },
  1123          maxSpeedSort: function (obj1, obj2) {
  1124              const speed1 = isNaN(this.getSpeed(obj1.maxspeed)) ? -1 : this.getSpeed(obj1.maxspeed);
  1125              const speed2 = isNaN(this.getSpeed(obj2.maxspeed)) ? -1 : this.getSpeed(obj2.maxspeed);
  1126              return speed1 - speed2;
  1127          },
  1128          filterPing: function (value, row) {
  1129              return value === "available" ? row.ping > 0 : true;
  1130          },
  1131          filterAvgSpeed: function (value, row) {
  1132              const speed = isNaN(this.getSpeed(row.speed)) ? -1 : this.getSpeed(row.speed);
  1133              return speed >= value;
  1134          },
  1135          filterMaxSpeed: function (value, row) {
  1136              const speed = isNaN(this.getSpeed(row.maxspeed)) ? -1 : this.getSpeed(row.maxspeed);
  1137              return speed >= value;
  1138          },
  1139          filterProtocol: function (value, row) {
  1140              if (value === "vmess") {
  1141                  return row.protocol.startsWith("vmess")
  1142              }
  1143              if (value === "trojan") {
  1144                  return row.protocol.startsWith("trojan")
  1145              }
  1146              return value === row.protocol
  1147          },
  1148          checkSelectable: function (row, index) {
  1149              return !!row.link && row.hasOwnProperty("id") && row.testing !== true
  1150          },
  1151      }
  1152  
  1153  }
  1154  
  1155  </script>
  1156  
  1157  <style>
  1158  
  1159  .ag-header-cell-label {
  1160     justify-content: center;
  1161  }
  1162  
  1163  </style>