mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
feat(endpoints): add the ability to browse offline endpoints (#2253)
* feat(back): saved data in snapshot * feat(endpoints): adding interceptors to retrieve saved data on offline endpoints * feat(endpoints): offline dashboard working - need tests on offline views * refactor(endpoints): interceptors cleaning and saving/loading offline endpoints data in/from localstorage * feat(endpoints): browsing offline endpoints * feat(endpoints): removing all the link in offline mode - sidebar not working when switching between off and on modes w/ stateManager logic * feat(endpoints): endpoint status detection in real time * fix(endpoints): offline swarm endpoint are not accessible anymore * fix(endpoints): refactor message + disable offline browsing for an endpoint when no snapshot is available for it * fix(endpoints): adding timeout and enabling loading bar for offline requests * fix(endpoints): trying to access a down endpoint wont remove sidebar items if it fails * feat(endpoints): disable checkboxes on offline views for offline mode * feat(endpoints): updating endpoint status when detecting a change * refactor(host): moved offline status panel from engine view to new host view * fix(endpoints): missing endpoint update on ping from home view * fix(api): rework EndpointUpdate operation * refactor(offline): moved endpoint status to EndpointProvider and refactor the status-changed detection * fix(offline): moved status detection to callback on views -> prevent displaying the offline message when endpoint is back online on view change * fix(offline): offline message is now displayed online when browsing an offline endpoint * fix(offline): sidebar updates correctly on endpoint status change * fix(offline): offline panel not displayed and hidden on online mode * refactor(offline): rework of OfflineMode management * refactor(offline): extract information-panel for offlineMode into a component * refactor(offline): remove redundant binding of informationPanel + endpointStatusInterceptor patter as service * refactor(interceptors): moved interceptors pattern to service pattern * feat(stacks): prevent inspection of a stack in offline mode * feat(host): hide devices/disk panels in offline mode * feat(host): disable browse action in offline mode * refactor(home): remove comments
This commit is contained in:
parent
354fda31f1
commit
a61654a35d
59 changed files with 637 additions and 212 deletions
|
@ -99,7 +99,7 @@
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<containers-datatable-actions
|
||||
<containers-datatable-actions ng-if="!$ctrl.offlineMode"
|
||||
selected-items="$ctrl.state.selectedItems"
|
||||
selected-item-count="$ctrl.state.selectedItemCount"
|
||||
no-stopped-items-selected="$ctrl.state.noStoppedItemsSelected"
|
||||
|
@ -116,7 +116,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span class="md-checkbox">
|
||||
<span class="md-checkbox" ng-if="!$ctrl.offlineMode">
|
||||
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
|
||||
<label for="select_all"></label>
|
||||
</span>
|
||||
|
@ -210,17 +210,18 @@
|
|||
<tbody>
|
||||
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
|
||||
<td>
|
||||
<span class="md-checkbox">
|
||||
<span class="md-checkbox" ng-if="!$ctrl.offlineMode">
|
||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||
<label for="select_{{ $index }}"></label>
|
||||
</span>
|
||||
<a ui-sref="docker.containers.container({ id: item.Id, nodeName: item.NodeName })" title="{{ item | containername }}">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
|
||||
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.containers.container({ id: item.Id, nodeName: item.NodeName })" title="{{ item | containername }}">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
|
||||
<span ng-if="$ctrl.offlineMode">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</span>
|
||||
</td>
|
||||
<td ng-show="$ctrl.columnVisibility.columns.state.display">
|
||||
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ item.Status }}</span>
|
||||
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) === -1" class="label label-{{ item.Status|containerstatusbadge }}">{{ item.Status }}</span>
|
||||
</td>
|
||||
<td ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect" ng-show="$ctrl.columnVisibility.columns.actions.display">
|
||||
<td ng-if="!$ctrl.offlineMode && ($ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect)" ng-show="$ctrl.columnVisibility.columns.actions.display">
|
||||
<div class="btn-group btn-group-xs" role="group" aria-label="..." style="display:inline-flex;">
|
||||
<a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="docker.containers.container.stats({id: item.Id, nodeName: item.NodeName})" title="Stats"><i class="fa fa-chart-area space-right" aria-hidden="true"></i></a>
|
||||
<a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="docker.containers.container.logs({id: item.Id, nodeName: item.NodeName})" title="Logs"><i class="fa fa-file-alt space-right" aria-hidden="true"></i></a>
|
||||
|
@ -228,8 +229,13 @@
|
|||
<a ng-if="$ctrl.settings.showQuickActionInspect" style="margin: 0 2.5px;" ui-sref="docker.containers.container.inspect({id: item.Id, nodeName: item.NodeName})" title="Inspect"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
<td ng-if="$ctrl.offlineMode">
|
||||
</td>
|
||||
<td ng-show="$ctrl.columnVisibility.columns.stack.display">{{ item.StackName ? item.StackName : '-' }}</td>
|
||||
<td ng-show="$ctrl.columnVisibility.columns.image.display"><a ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a></td>
|
||||
<td ng-show="$ctrl.columnVisibility.columns.image.display">
|
||||
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a>
|
||||
<span ng-if="$ctrl.offlineMode">{{ item.Image | trimshasum }}</span>
|
||||
</td>
|
||||
<td ng-show="$ctrl.columnVisibility.columns.created.display">
|
||||
{{item.Created | getisodatefromtimestamp}}
|
||||
</td>
|
||||
|
|
|
@ -10,6 +10,7 @@ angular.module('portainer.docker').component('containersDatatable', {
|
|||
reverseOrder: '<',
|
||||
showOwnershipColumn: '<',
|
||||
showHostColumn: '<',
|
||||
showAddAction: '<'
|
||||
showAddAction: '<',
|
||||
offlineMode: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -204,7 +204,6 @@ function (PaginationService, DatatableService, EndpointProvider) {
|
|||
this.$onInit = function() {
|
||||
setDefaults(this);
|
||||
this.prepareTableFromDataset();
|
||||
|
||||
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
|
||||
if (storedOrder !== null) {
|
||||
this.state.reverseOrder = storedOrder.reverse;
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actionBar">
|
||||
<div class="actionBar" ng-if="!$ctrl.offlineMode">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-danger"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems, false)">
|
||||
|
@ -44,7 +44,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th uib-dropdown dropdown-append-to-body auto-close="disabled" popover-placement="bottom-left" is-open="$ctrl.filters.usage.open">
|
||||
<span class="md-checkbox">
|
||||
<span class="md-checkbox" ng-if="!$ctrl.offlineMode">
|
||||
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
|
||||
<label for="select_all"></label>
|
||||
</span>
|
||||
|
@ -111,11 +111,12 @@
|
|||
<tbody>
|
||||
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
|
||||
<td>
|
||||
<span class="md-checkbox">
|
||||
<span class="md-checkbox" ng-if="!$ctrl.offlineMode">
|
||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||
<label for="select_{{ $index }}"></label>
|
||||
</span>
|
||||
<a ui-sref="docker.images.image({ id: item.Id, nodeName: item.NodeName })" class="monospaced" title="{{ item.Id }}">{{ item.Id | truncate:40 }}</a>
|
||||
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.images.image({ id: item.Id, nodeName: item.NodeName })" class="monospaced" title="{{ item.Id }}">{{ item.Id | truncate:40 }}</a>
|
||||
<span ng-if="$ctrl.offlineMode">{{ item.Id | truncate:40 }}</span>
|
||||
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="::item.ContainerCount === 0">Unused</span>
|
||||
</td>
|
||||
<td>
|
||||
|
|
|
@ -12,6 +12,7 @@ angular.module('portainer.docker').component('imagesDatatable', {
|
|||
removeAction: '<',
|
||||
downloadAction: '<',
|
||||
forceRemoveAction: '<',
|
||||
exportInProgress: '<'
|
||||
exportInProgress: '<',
|
||||
offlineMode: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actionBar">
|
||||
<div class="actionBar" ng-if="!$ctrl.offlineMode">
|
||||
<button type="button" class="btn btn-sm btn-danger"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
|
@ -24,7 +24,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span class="md-checkbox">
|
||||
<span class="md-checkbox" ng-if="!$ctrl.offlineMode">
|
||||
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
|
||||
<label for="select_all"></label>
|
||||
</span>
|
||||
|
@ -109,11 +109,12 @@
|
|||
<tbody>
|
||||
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
|
||||
<td>
|
||||
<span class="md-checkbox">
|
||||
<span class="md-checkbox" ng-if="!$ctrl.offlineMode">
|
||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||
<label for="select_{{ $index }}"></label>
|
||||
</span>
|
||||
<a ui-sref="docker.networks.network({ id: item.Id, nodeName: item.NodeName })" title="{{ item.Name }}">{{ item.Name | truncate:40 }}</a>
|
||||
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.networks.network({ id: item.Id, nodeName: item.NodeName })" title="{{ item.Name }}">{{ item.Name | truncate:40 }}</a>
|
||||
<span ng-if="$ctrl.offlineMode">{{ item.Name | truncate:40 }}</span>
|
||||
</td>
|
||||
<td>{{ item.StackName ? item.StackName : '-' }}</td>
|
||||
<td>{{ item.Scope }}</td>
|
||||
|
|
|
@ -10,6 +10,7 @@ angular.module('portainer.docker').component('networksDatatable', {
|
|||
reverseOrder: '<',
|
||||
showOwnershipColumn: '<',
|
||||
showHostColumn: '<',
|
||||
removeAction: '<'
|
||||
removeAction: '<',
|
||||
offlineMode: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actionBar">
|
||||
<div class="actionBar" ng-if="!$ctrl.offlineMode">
|
||||
<button type="button" class="btn btn-sm btn-danger"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
|
@ -24,7 +24,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.usage.open">
|
||||
<span class="md-checkbox">
|
||||
<span class="md-checkbox" ng-if="!$ctrl.offlineMode">
|
||||
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
|
||||
<label for="select_all"></label>
|
||||
</span>
|
||||
|
@ -98,12 +98,13 @@
|
|||
<tbody>
|
||||
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
|
||||
<td>
|
||||
<span class="md-checkbox">
|
||||
<span class="md-checkbox" ng-if="!$ctrl.offlineMode">
|
||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||
<label for="select_{{ $index }}"></label>
|
||||
</span>
|
||||
<a ui-sref="docker.volumes.volume({ id: item.Id, nodeName: item.NodeName })" class="monospaced" title="{{ item.Id }}">{{ item.Id | truncate:40 }}</a>
|
||||
<a ui-sref="docker.volumes.volume.browse({ id: item.Id, nodeName: item.NodeName })" class="btn btn-xs btn-primary space-left" ng-if="$ctrl.showBrowseAction">
|
||||
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.volumes.volume({ id: item.Id, nodeName: item.NodeName })" class="monospaced" title="{{ item.Id }}">{{ item.Id | truncate:40 }}</a>
|
||||
<span ng-if="$ctrl.offlineMode">{{ item.Id | truncate:40 }}</span>
|
||||
<a ui-sref="docker.volumes.volume.browse({ id: item.Id, nodeName: item.NodeName })" class="btn btn-xs btn-primary space-left" ng-if="$ctrl.showBrowseAction && !$ctrl.offlineMode">
|
||||
<i class="fa fa-search"></i> browse</a>
|
||||
</a>
|
||||
<span style="margin-left: 10px;" class="label label-warning image-tag space-left" ng-if="item.dangling">Unused</span>
|
||||
|
|
|
@ -11,6 +11,7 @@ angular.module('portainer.docker').component('volumesDatatable', {
|
|||
showOwnershipColumn: '<',
|
||||
showHostColumn: '<',
|
||||
removeAction: '<',
|
||||
showBrowseAction: '<'
|
||||
showBrowseAction: '<',
|
||||
offlineMode: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -4,6 +4,7 @@ angular.module('portainer.docker').component('dockerSidebarContent', {
|
|||
endpointApiVersion: '<',
|
||||
swarmManagement: '<',
|
||||
standaloneManagement: '<',
|
||||
adminAccess: '<'
|
||||
adminAccess: '<',
|
||||
offlineMode: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<li class="sidebar-list">
|
||||
<a ui-sref="docker.dashboard" ui-sref-active="active">Dashboard <span class="menu-icon fa fa-tachometer-alt fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
<li class="sidebar-list" ng-if="!$ctrl.offlineMode">
|
||||
<a ui-sref="portainer.templates" ui-sref-active="active">App Templates <span class="menu-icon fa fa-rocket fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
|
@ -28,7 +28,7 @@
|
|||
<li class="sidebar-list" ng-if="$ctrl.endpointApiVersion >= 1.25 && $ctrl.swarmManagement">
|
||||
<a ui-sref="docker.secrets" ui-sref-active="active">Secrets <span class="menu-icon fa fa-user-secret fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="$ctrl.standaloneManagement && $ctrl.adminAccess">
|
||||
<li class="sidebar-list" ng-if="$ctrl.standaloneManagement && $ctrl.adminAccess && !$ctrl.offlineMode">
|
||||
<a ui-sref="docker.events" ui-sref-active="active">Events <span class="menu-icon fa fa-history fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="$ctrl.swarmManagement">
|
||||
|
|
|
@ -8,25 +8,27 @@
|
|||
<rd-header-content>Docker</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<information-panel-offline ng-if="$ctrl.offlineMode"></information-panel-offline>
|
||||
|
||||
<host-details-panel
|
||||
host="$ctrl.hostDetails"
|
||||
is-browse-enabled="$ctrl.isAgent && $ctrl.agentApiVersion > 1"
|
||||
is-browse-enabled="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && !$ctrl.offlineMode"
|
||||
browse-url="{{$ctrl.browseUrl}}"
|
||||
is-job-enabled="$ctrl.isJobEnabled"
|
||||
is-job-enabled="$ctrl.isJobEnabled && !$ctrl.offlineMode"
|
||||
job-url="{{$ctrl.jobUrl}}"
|
||||
></host-details-panel>
|
||||
|
||||
<engine-details-panel engine="$ctrl.engineDetails"></engine-details-panel>
|
||||
|
||||
<jobs-datatable
|
||||
ng-if="$ctrl.isJobEnabled && $ctrl.jobs"
|
||||
ng-if="$ctrl.isJobEnabled && $ctrl.jobs && !$ctrl.offlineMode"
|
||||
title-text="Jobs" title-icon="fa-tasks"
|
||||
dataset="$ctrl.jobs"
|
||||
table-key="jobs"
|
||||
order-by="Created" reverse-order="true"
|
||||
></jobs-datatable>
|
||||
|
||||
<devices-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1" devices="$ctrl.devices"></devices-panel>
|
||||
<disks-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1" disks="$ctrl.disks"></disks-panel>
|
||||
<devices-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && !$ctrl.offlineMode" devices="$ctrl.devices"></devices-panel>
|
||||
<disks-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && !$ctrl.offlineMode" disks="$ctrl.disks"></disks-panel>
|
||||
|
||||
<ng-transclude></ng-transclude>
|
||||
|
|
|
@ -6,6 +6,7 @@ angular.module('portainer.docker').component('hostOverview', {
|
|||
devices: '<',
|
||||
disks: '<',
|
||||
isAgent: '<',
|
||||
offlineMode: '<',
|
||||
agentApiVersion: '<',
|
||||
refreshUrl: '@',
|
||||
browseUrl: '@',
|
||||
|
|
21
app/docker/interceptors/containersInterceptor.js
Normal file
21
app/docker/interceptors/containersInterceptor.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
angular.module('portainer.app')
|
||||
.factory('ContainersInterceptor', ['$q', 'EndpointProvider', function ($q, EndpointProvider) {
|
||||
'use strict';
|
||||
var interceptor = {};
|
||||
|
||||
interceptor.responseError = responseErrorInterceptor;
|
||||
|
||||
function responseErrorInterceptor(rejection) {
|
||||
if (rejection.status === 502 || rejection.status === -1) {
|
||||
var endpoint = EndpointProvider.currentEndpoint();
|
||||
if (endpoint !== undefined) {
|
||||
var data = endpoint.Snapshots[0].SnapshotRaw.Containers;
|
||||
if (data !== undefined) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $q.reject(rejection);
|
||||
}
|
||||
return interceptor;
|
||||
}]);
|
21
app/docker/interceptors/imagesInterceptor.js
Normal file
21
app/docker/interceptors/imagesInterceptor.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
angular.module('portainer.app')
|
||||
.factory('ImagesInterceptor', ['$q', 'EndpointProvider', function ($q, EndpointProvider) {
|
||||
'use strict';
|
||||
var interceptor = {};
|
||||
|
||||
interceptor.responseError = responseErrorInterceptor;
|
||||
|
||||
function responseErrorInterceptor(rejection) {
|
||||
if (rejection.status === 502 || rejection.status === -1) {
|
||||
var endpoint = EndpointProvider.currentEndpoint();
|
||||
if (endpoint !== undefined) {
|
||||
var data = endpoint.Snapshots[0].SnapshotRaw.Images;
|
||||
if (data !== undefined) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $q.reject(rejection);
|
||||
}
|
||||
return interceptor;
|
||||
}]);
|
21
app/docker/interceptors/infoInterceptor.js
Normal file
21
app/docker/interceptors/infoInterceptor.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
angular.module('portainer.app')
|
||||
.factory('InfoInterceptor', ['$q', 'EndpointProvider', function ($q, EndpointProvider) {
|
||||
'use strict';
|
||||
var interceptor = {};
|
||||
|
||||
interceptor.responseError = responseErrorInterceptor;
|
||||
|
||||
function responseErrorInterceptor(rejection) {
|
||||
if (rejection.status === 502 || rejection.status === -1) {
|
||||
var endpoint = EndpointProvider.currentEndpoint();
|
||||
if (endpoint !== undefined) {
|
||||
var data = endpoint.Snapshots[0].SnapshotRaw.Info;
|
||||
if (data !== undefined) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $q.reject(rejection);
|
||||
}
|
||||
return interceptor;
|
||||
}]);
|
21
app/docker/interceptors/networksInterceptor.js
Normal file
21
app/docker/interceptors/networksInterceptor.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
angular.module('portainer.app')
|
||||
.factory('NetworksInterceptor', ['$q', 'EndpointProvider', function ($q, EndpointProvider) {
|
||||
'use strict';
|
||||
var interceptor = {};
|
||||
|
||||
interceptor.responseError = responseErrorInterceptor;
|
||||
|
||||
function responseErrorInterceptor(rejection) {
|
||||
if (rejection.status === 502 || rejection.status === -1) {
|
||||
var endpoint = EndpointProvider.currentEndpoint();
|
||||
if (endpoint !== undefined) {
|
||||
var data = endpoint.Snapshots[0].SnapshotRaw.Networks;
|
||||
if (data !== undefined) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $q.reject(rejection);
|
||||
}
|
||||
return interceptor;
|
||||
}]);
|
21
app/docker/interceptors/versionInterceptor.js
Normal file
21
app/docker/interceptors/versionInterceptor.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
angular.module('portainer.app')
|
||||
.factory('VersionInterceptor', ['$q', 'EndpointProvider', function ($q, EndpointProvider) {
|
||||
'use strict';
|
||||
var interceptor = {};
|
||||
|
||||
interceptor.responseError = responseErrorInterceptor;
|
||||
|
||||
function responseErrorInterceptor(rejection) {
|
||||
if (rejection.status === 502 || rejection.status === -1) {
|
||||
var endpoint = EndpointProvider.currentEndpoint();
|
||||
if (endpoint !== undefined) {
|
||||
var data = endpoint.Snapshots[0].SnapshotRaw.Version;
|
||||
if (data !== undefined) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $q.reject(rejection);
|
||||
}
|
||||
return interceptor;
|
||||
}]);
|
21
app/docker/interceptors/volumesInterceptor.js
Normal file
21
app/docker/interceptors/volumesInterceptor.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
angular.module('portainer.app')
|
||||
.factory('VolumesInterceptor', ['$q', 'EndpointProvider', function ($q, EndpointProvider) {
|
||||
'use strict';
|
||||
var interceptor = {};
|
||||
|
||||
interceptor.responseError = responseErrorInterceptor;
|
||||
|
||||
function responseErrorInterceptor(rejection) {
|
||||
if (rejection.status === 502 || rejection.status === -1) {
|
||||
var endpoint = EndpointProvider.currentEndpoint();
|
||||
if (endpoint !== undefined) {
|
||||
var data = endpoint.Snapshots[0].SnapshotRaw.Volumes;
|
||||
if (data !== undefined) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $q.reject(rejection);
|
||||
}
|
||||
return interceptor;
|
||||
}]);
|
|
@ -1,6 +1,6 @@
|
|||
angular.module('portainer.docker')
|
||||
.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider',
|
||||
function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
|
||||
.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'ContainersInterceptor',
|
||||
function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, ContainersInterceptor) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/containers/:id/:action', {
|
||||
name: '@name',
|
||||
|
@ -9,7 +9,7 @@ function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
|
|||
{
|
||||
query: {
|
||||
method: 'GET', params: { all: 0, action: 'json', filters: '@filters' },
|
||||
isArray: true
|
||||
isArray: true, interceptor: ContainersInterceptor, timeout: 10000
|
||||
},
|
||||
get: {
|
||||
method: 'GET', params: { action: 'json' }
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
angular.module('portainer.docker')
|
||||
.factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper',
|
||||
function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) {
|
||||
.factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', 'ImagesInterceptor',
|
||||
function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper, ImagesInterceptor) {
|
||||
'use strict';
|
||||
|
||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/images/:id/:action', {
|
||||
endpointId: EndpointProvider.endpointID
|
||||
},
|
||||
{
|
||||
query: {method: 'GET', params: {all: 0, action: 'json'}, isArray: true},
|
||||
query: {method: 'GET', params: {all: 0, action: 'json'}, isArray: true, interceptor: ImagesInterceptor, timeout: 10000},
|
||||
get: {method: 'GET', params: {action: 'json'}},
|
||||
search: {method: 'GET', params: {action: 'search'}},
|
||||
history: {method: 'GET', params: {action: 'history'}, isArray: true},
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
angular.module('portainer.docker')
|
||||
.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider',
|
||||
function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
|
||||
.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'NetworksInterceptor',
|
||||
function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, NetworksInterceptor) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', {
|
||||
id: '@id',
|
||||
|
@ -8,7 +8,7 @@ function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
|
|||
},
|
||||
{
|
||||
query: {
|
||||
method: 'GET', isArray: true
|
||||
method: 'GET', isArray: true, interceptor: NetworksInterceptor, timeout: 10000
|
||||
},
|
||||
get: {
|
||||
method: 'GET'
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
angular.module('portainer.docker')
|
||||
.factory('System', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function SystemFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
|
||||
.factory('System', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'InfoInterceptor', 'VersionInterceptor',
|
||||
function SystemFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, InfoInterceptor, VersionInterceptor) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/:action/:subAction', {
|
||||
name: '@name',
|
||||
|
@ -7,10 +8,9 @@ angular.module('portainer.docker')
|
|||
},
|
||||
{
|
||||
info: {
|
||||
method: 'GET', params: { action: 'info' },
|
||||
ignoreLoadingBar: true
|
||||
method: 'GET', params: { action: 'info' }, timeout: 10000, interceptor: InfoInterceptor
|
||||
},
|
||||
version: { method: 'GET', params: { action: 'version' }, ignoreLoadingBar: true, timeout: 4500 },
|
||||
version: { method: 'GET', params: { action: 'version' }, timeout: 4500, interceptor: VersionInterceptor },
|
||||
events: {
|
||||
method: 'GET', params: { action: 'events', since: '@since', until: '@until' },
|
||||
isArray: true, transformResponse: jsonObjectsToArrayHandler
|
||||
|
|
13
app/docker/rest/systemEndpoint.js
Normal file
13
app/docker/rest/systemEndpoint.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
angular.module('portainer.docker')
|
||||
.factory('SystemEndpoint', ['$resource', 'API_ENDPOINT_ENDPOINTS',
|
||||
function SystemEndpointFactory($resource, API_ENDPOINT_ENDPOINTS) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/:action/:subAction', {
|
||||
name: '@name'
|
||||
},
|
||||
{
|
||||
ping: {
|
||||
method: 'GET', params: { action: '_ping', endpointId: '@endpointId' }, timeout: 10000
|
||||
}
|
||||
});
|
||||
}]);
|
|
@ -1,12 +1,13 @@
|
|||
angular.module('portainer.docker')
|
||||
.factory('Volume', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function VolumeFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
|
||||
.factory('Volume', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'VolumesInterceptor',
|
||||
function VolumeFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, VolumesInterceptor) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/volumes/:id/:action',
|
||||
{
|
||||
endpointId: EndpointProvider.endpointID
|
||||
},
|
||||
{
|
||||
query: { method: 'GET' },
|
||||
query: { method: 'GET', interceptor: VolumesInterceptor, timeout: 10000},
|
||||
get: { method: 'GET', params: {id: '@id'} },
|
||||
create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler, ignoreLoadingBar: true},
|
||||
remove: {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
angular.module('portainer.docker')
|
||||
.factory('SystemService', ['$q', 'System', function SystemServiceFactory($q, System) {
|
||||
.factory('SystemService', ['$q', 'System', 'SystemEndpoint', function SystemServiceFactory($q, System, SystemEndpoint) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
|
@ -20,6 +20,10 @@ angular.module('portainer.docker')
|
|||
return System.info({}).$promise;
|
||||
};
|
||||
|
||||
service.ping = function(endpointId) {
|
||||
return SystemEndpoint.ping({endpointId: endpointId}).$promise;
|
||||
};
|
||||
|
||||
service.version = function() {
|
||||
return System.version({}).$promise;
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
</rd-header-title>
|
||||
<rd-header-content>Containers</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<information-panel-offline ng-if="offlineMode"></information-panel-offline>
|
||||
<div class="row">
|
||||
<div class="col-sm-12" ng-if="containers">
|
||||
<containers-datatable
|
||||
|
@ -16,6 +16,7 @@
|
|||
show-ownership-column="applicationState.application.authentication"
|
||||
show-host-column="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
|
||||
show-add-action="true"
|
||||
offline-mode="offlineMode"
|
||||
></containers-datatable>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
angular.module('portainer.docker')
|
||||
.controller('ContainersController', ['$scope', 'ContainerService', 'Notifications',
|
||||
function ($scope, ContainerService, Notifications) {
|
||||
.controller('ContainersController', ['$scope', 'ContainerService', 'Notifications', 'EndpointProvider',
|
||||
function ($scope, ContainerService, Notifications, EndpointProvider) {
|
||||
|
||||
$scope.offlineMode = false;
|
||||
|
||||
function initView() {
|
||||
ContainerService.containers(1)
|
||||
.then(function success(data) {
|
||||
$scope.containers = data;
|
||||
$scope.offlineMode = EndpointProvider.offlineMode();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve containers');
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<dashboard-cluster-agent-info></dashboard-cluster-agent-info>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<information-panel-offline ng-if="offlineMode"></information-panel-offline>
|
||||
<information-panel
|
||||
ng-if="!applicationState.UI.dismissedInfoPanels['docker-dashboard-info-01'] && !applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
|
||||
title-text="Information"
|
||||
|
|
|
@ -6,6 +6,8 @@ function ($scope, $q, ContainerService, ImageService, NetworkService, VolumeServ
|
|||
StateManager.dismissInformationPanel(id);
|
||||
};
|
||||
|
||||
$scope.offlineMode = false;
|
||||
|
||||
function initView() {
|
||||
var endpointMode = $scope.applicationState.endpoint.mode;
|
||||
var endpointId = EndpointProvider.endpointID();
|
||||
|
@ -29,6 +31,7 @@ function ($scope, $q, ContainerService, ImageService, NetworkService, VolumeServ
|
|||
$scope.stackCount = data.stacks.length;
|
||||
$scope.info = data.info;
|
||||
$scope.endpoint = data.endpoint;
|
||||
$scope.offlineMode = EndpointProvider.offlineMode();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to load dashboard data');
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
angular.module('portainer.docker').controller('HostViewController', [
|
||||
'$q', 'SystemService', 'Notifications', 'StateManager', 'AgentService', 'ContainerService', 'Authentication',
|
||||
function HostViewController($q, SystemService, Notifications, StateManager, AgentService, ContainerService, Authentication) {
|
||||
'$q', 'SystemService', 'Notifications', 'StateManager', 'AgentService', 'ContainerService', 'Authentication', 'EndpointProvider',
|
||||
function HostViewController($q, SystemService, Notifications, StateManager, AgentService, ContainerService, Authentication, EndpointProvider) {
|
||||
var ctrl = this;
|
||||
|
||||
this.$onInit = initView;
|
||||
|
||||
ctrl.state = {
|
||||
isAgent: false,
|
||||
isAdmin : false
|
||||
isAdmin : false,
|
||||
offlineMode: false
|
||||
};
|
||||
|
||||
this.engineDetails = {};
|
||||
|
@ -30,6 +31,7 @@ angular.module('portainer.docker').controller('HostViewController', [
|
|||
.then(function success(data) {
|
||||
ctrl.engineDetails = buildEngineDetails(data);
|
||||
ctrl.hostDetails = buildHostDetails(data.info);
|
||||
ctrl.state.offlineMode = EndpointProvider.offlineMode();
|
||||
ctrl.jobs = data.jobs;
|
||||
|
||||
if (ctrl.state.isAgent && agentApiVersion > 1) {
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
devices="$ctrl.devices"
|
||||
refresh-url="docker.host"
|
||||
browse-url="docker.host.browser"
|
||||
is-job-enabled="$ctrl.state.isAdmin"
|
||||
offline-mode="$ctrl.state.offlineMode"
|
||||
is-job-enabled="$ctrl.state.isAdmin && !$ctrl.state.offlineMode"
|
||||
job-url="docker.host.job"
|
||||
jobs="$ctrl.jobs"
|
||||
></host-overview>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<rd-header-content>Images</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="row" ng-if="!offlineMode">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-download" title-text="Pull image ">
|
||||
|
@ -51,7 +51,7 @@
|
|||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<information-panel-offline ng-if="offlineMode"></information-panel-offline>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<images-datatable
|
||||
|
@ -63,6 +63,7 @@
|
|||
remove-action="removeAction"
|
||||
force-remove-action="confirmRemovalAction"
|
||||
export-in-progress="state.exportInProgress"
|
||||
offline-mode="offlineMode"
|
||||
></images-datatable>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
angular.module('portainer.docker')
|
||||
.controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'ModalService', 'HttpRequestHelper', 'FileSaver', 'Blob',
|
||||
function ($scope, $state, ImageService, Notifications, ModalService, HttpRequestHelper, FileSaver, Blob) {
|
||||
.controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'ModalService', 'HttpRequestHelper', 'FileSaver', 'Blob', 'EndpointProvider',
|
||||
function ($scope, $state, ImageService, Notifications, ModalService, HttpRequestHelper, FileSaver, Blob, EndpointProvider) {
|
||||
$scope.state = {
|
||||
actionInProgress: false,
|
||||
exportInProgress: false
|
||||
|
@ -113,10 +113,13 @@ function ($scope, $state, ImageService, Notifications, ModalService, HttpRequest
|
|||
});
|
||||
};
|
||||
|
||||
$scope.offlineMode = false;
|
||||
|
||||
function initView() {
|
||||
ImageService.images(true)
|
||||
.then(function success(data) {
|
||||
$scope.images = data;
|
||||
$scope.offlineMode = EndpointProvider.offlineMode();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve images');
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
</rd-header-title>
|
||||
<rd-header-content>Networks</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<information-panel-offline ng-if="offlineMode"></information-panel-offline>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<networks-datatable
|
||||
|
@ -16,6 +16,7 @@
|
|||
remove-action="removeAction"
|
||||
show-ownership-column="applicationState.application.authentication"
|
||||
show-host-column="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
|
||||
offline-mode="offlineMode"
|
||||
></networks-datatable>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
angular.module('portainer.docker')
|
||||
.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper',
|
||||
function ($scope, $state, NetworkService, Notifications, HttpRequestHelper) {
|
||||
.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', 'EndpointProvider',
|
||||
function ($scope, $state, NetworkService, Notifications, HttpRequestHelper, EndpointProvider) {
|
||||
|
||||
$scope.removeAction = function (selectedItems) {
|
||||
var actionCount = selectedItems.length;
|
||||
|
@ -24,10 +24,13 @@ function ($scope, $state, NetworkService, Notifications, HttpRequestHelper) {
|
|||
});
|
||||
};
|
||||
|
||||
$scope.offlineMode = false;
|
||||
|
||||
function initView() {
|
||||
NetworkService.networks(true, true, true)
|
||||
.then(function success(data) {
|
||||
$scope.networks = data;
|
||||
$scope.offlineMode = EndpointProvider.offlineMode();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
$scope.networks = [];
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
</rd-header-title>
|
||||
<rd-header-content>Volumes</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<information-panel-offline ng-if="offlineMode"></information-panel-offline>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<volumes-datatable
|
||||
|
@ -17,6 +17,7 @@
|
|||
show-ownership-column="applicationState.application.authentication"
|
||||
show-host-column="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
|
||||
show-browse-action="applicationState.endpoint.mode.agentProxy"
|
||||
offline-mode="offlineMode"
|
||||
></volumes-datatable>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
angular.module('portainer.docker')
|
||||
.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper',
|
||||
function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper) {
|
||||
.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', 'EndpointProvider',
|
||||
function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, EndpointProvider) {
|
||||
|
||||
$scope.removeAction = function (selectedItems) {
|
||||
var actionCount = selectedItems.length;
|
||||
|
@ -24,6 +24,8 @@ function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notif
|
|||
});
|
||||
};
|
||||
|
||||
$scope.offlineMode = false;
|
||||
|
||||
function initView() {
|
||||
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
|
||||
var endpointRole = $scope.applicationState.endpoint.mode.role;
|
||||
|
@ -35,6 +37,7 @@ function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notif
|
|||
})
|
||||
.then(function success(data) {
|
||||
var services = data.services;
|
||||
$scope.offlineMode = EndpointProvider.offlineMode();
|
||||
$scope.volumes = data.attached.map(function(volume) {
|
||||
volume.dangling = false;
|
||||
return volume;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue