mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
dataproxy: added caching of datasources when doing data proxy requests, #9078
This commit is contained in:
@@ -217,8 +217,8 @@ func (hs *HttpServer) registerRoutes() {
|
|||||||
}, reqOrgAdmin)
|
}, reqOrgAdmin)
|
||||||
|
|
||||||
r.Get("/frontend/settings/", GetFrontendSettings)
|
r.Get("/frontend/settings/", GetFrontendSettings)
|
||||||
r.Any("/datasources/proxy/:id/*", reqSignedIn, ProxyDataSourceRequest)
|
r.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest)
|
||||||
r.Any("/datasources/proxy/:id", reqSignedIn, ProxyDataSourceRequest)
|
r.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest)
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
r.Group("/dashboards", func() {
|
r.Group("/dashboards", func() {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/pluginproxy"
|
"github.com/grafana/grafana/pkg/api/pluginproxy"
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
"github.com/grafana/grafana/pkg/metrics"
|
"github.com/grafana/grafana/pkg/metrics"
|
||||||
@@ -9,19 +12,35 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getDatasource(id int64, orgId int64) (*m.DataSource, error) {
|
const HeaderNameNoBackendCache = "X-Grafana-NoCache"
|
||||||
|
|
||||||
|
func (hs *HttpServer) getDatasourceById(id int64, orgId int64, nocache bool) (*m.DataSource, error) {
|
||||||
|
cacheKey := fmt.Sprintf("ds-%d", id)
|
||||||
|
|
||||||
|
if !nocache {
|
||||||
|
if cached, found := hs.cache.Get(cacheKey); found {
|
||||||
|
ds := cached.(*m.DataSource)
|
||||||
|
if ds.OrgId == orgId {
|
||||||
|
return ds, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
query := m.GetDataSourceByIdQuery{Id: id, OrgId: orgId}
|
query := m.GetDataSourceByIdQuery{Id: id, OrgId: orgId}
|
||||||
if err := bus.Dispatch(&query); err != nil {
|
if err := bus.Dispatch(&query); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hs.cache.Set(cacheKey, query.Result, time.Second*5)
|
||||||
return query.Result, nil
|
return query.Result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProxyDataSourceRequest(c *middleware.Context) {
|
func (hs *HttpServer) ProxyDataSourceRequest(c *middleware.Context) {
|
||||||
c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer)
|
c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer)
|
||||||
|
|
||||||
ds, err := getDatasource(c.ParamsInt64(":id"), c.OrgId)
|
nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true"
|
||||||
|
|
||||||
|
ds, err := hs.getDatasourceById(c.ParamsInt64(":id"), c.OrgId, nocache)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JsonApiErr(500, "Unable to load datasource meta data", err)
|
c.JsonApiErr(500, "Unable to load datasource meta data", err)
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gocache "github.com/patrickmn/go-cache"
|
||||||
macaron "gopkg.in/macaron.v1"
|
macaron "gopkg.in/macaron.v1"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/live"
|
"github.com/grafana/grafana/pkg/api/live"
|
||||||
@@ -29,13 +31,15 @@ type HttpServer struct {
|
|||||||
macaron *macaron.Macaron
|
macaron *macaron.Macaron
|
||||||
context context.Context
|
context context.Context
|
||||||
streamManager *live.StreamManager
|
streamManager *live.StreamManager
|
||||||
|
cache *gocache.Cache
|
||||||
|
|
||||||
httpSrv *http.Server
|
httpSrv *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHttpServer() *HttpServer {
|
func NewHttpServer() *HttpServer {
|
||||||
return &HttpServer{
|
return &HttpServer{
|
||||||
log: log.New("http.server"),
|
log: log.New("http.server"),
|
||||||
|
cache: gocache.New(5*time.Minute, 10*time.Minute),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ var (
|
|||||||
M_Alerting_Notification_Sent_Pushover Counter
|
M_Alerting_Notification_Sent_Pushover Counter
|
||||||
M_Aws_CloudWatch_GetMetricStatistics Counter
|
M_Aws_CloudWatch_GetMetricStatistics Counter
|
||||||
M_Aws_CloudWatch_ListMetrics Counter
|
M_Aws_CloudWatch_ListMetrics Counter
|
||||||
|
M_DB_DataSource_QueryById Counter
|
||||||
|
|
||||||
// Timers
|
// Timers
|
||||||
M_DataSource_ProxyReq_Timer Timer
|
M_DataSource_ProxyReq_Timer Timer
|
||||||
@@ -130,6 +131,8 @@ func initMetricVars(settings *MetricSettings) {
|
|||||||
M_Aws_CloudWatch_GetMetricStatistics = RegCounter("aws.cloudwatch.get_metric_statistics")
|
M_Aws_CloudWatch_GetMetricStatistics = RegCounter("aws.cloudwatch.get_metric_statistics")
|
||||||
M_Aws_CloudWatch_ListMetrics = RegCounter("aws.cloudwatch.list_metrics")
|
M_Aws_CloudWatch_ListMetrics = RegCounter("aws.cloudwatch.list_metrics")
|
||||||
|
|
||||||
|
M_DB_DataSource_QueryById = RegCounter("db.datasource.query_by_id")
|
||||||
|
|
||||||
// Timers
|
// Timers
|
||||||
M_DataSource_ProxyReq_Timer = RegTimer("api.dataproxy.request.all")
|
M_DataSource_ProxyReq_Timer = RegTimer("api.dataproxy.request.all")
|
||||||
M_Alerting_Execution_Time = RegTimer("alerting.execution_time")
|
M_Alerting_Execution_Time = RegTimer("alerting.execution_time")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
"github.com/grafana/grafana/pkg/components/securejsondata"
|
"github.com/grafana/grafana/pkg/components/securejsondata"
|
||||||
|
"github.com/grafana/grafana/pkg/metrics"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetDataSourceById(query *m.GetDataSourceByIdQuery) error {
|
func GetDataSourceById(query *m.GetDataSourceByIdQuery) error {
|
||||||
|
metrics.M_DB_DataSource_QueryById.Inc(1)
|
||||||
|
|
||||||
datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id}
|
datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id}
|
||||||
has, err := x.Get(&datasource)
|
has, err := x.Get(&datasource)
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import coreModule from 'app/core/core_module';
|
|||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
|
|
||||||
export class BackendSrv {
|
export class BackendSrv {
|
||||||
inFlightRequests = {};
|
private inFlightRequests = {};
|
||||||
HTTP_REQUEST_CANCELLED = -1;
|
private HTTP_REQUEST_CANCELLED = -1;
|
||||||
|
private noBackendCache: boolean;
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor(private $http, private alertSrv, private $rootScope, private $q, private $timeout, private contextSrv) {
|
constructor(private $http, private alertSrv, private $rootScope, private $q, private $timeout, private contextSrv) {
|
||||||
@@ -34,6 +35,13 @@ export class BackendSrv {
|
|||||||
return this.request({ method: 'PUT', url: url, data: data });
|
return this.request({ method: 'PUT', url: url, data: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withNoBackendCache(callback) {
|
||||||
|
this.noBackendCache = true;
|
||||||
|
return callback().finally(() => {
|
||||||
|
this.noBackendCache = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
requestErrorHandler(err) {
|
requestErrorHandler(err) {
|
||||||
if (err.isHandled) {
|
if (err.isHandled) {
|
||||||
return;
|
return;
|
||||||
@@ -149,6 +157,10 @@ export class BackendSrv {
|
|||||||
options.headers['X-DS-Authorization'] = options.headers.Authorization;
|
options.headers['X-DS-Authorization'] = options.headers.Authorization;
|
||||||
delete options.headers.Authorization;
|
delete options.headers.Authorization;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.noBackendCache) {
|
||||||
|
options.headers['X-Grafana-NoCache'] = 'true';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.$http(options).then(response => {
|
return this.$http(options).then(response => {
|
||||||
|
|||||||
@@ -111,31 +111,31 @@ export class DataSourceEditCtrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
testDatasource() {
|
testDatasource() {
|
||||||
this.testing = { done: false };
|
|
||||||
|
|
||||||
this.datasourceSrv.get(this.current.name).then(datasource => {
|
this.datasourceSrv.get(this.current.name).then(datasource => {
|
||||||
if (!datasource.testDatasource) {
|
if (!datasource.testDatasource) {
|
||||||
delete this.testing;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return datasource.testDatasource().then(result => {
|
this.testing = {done: false};
|
||||||
this.testing.message = result.message;
|
|
||||||
this.testing.status = result.status;
|
// make test call in no backend cache context
|
||||||
this.testing.title = result.title;
|
this.backendSrv.withNoBackendCache(() => {
|
||||||
}).catch(err => {
|
return datasource.testDatasource().then(result => {
|
||||||
if (err.statusText) {
|
this.testing.message = result.message;
|
||||||
this.testing.message = err.statusText;
|
this.testing.status = result.status;
|
||||||
this.testing.title = "HTTP Error";
|
this.testing.title = result.title;
|
||||||
} else {
|
}).catch(err => {
|
||||||
this.testing.message = err.message;
|
if (err.statusText) {
|
||||||
this.testing.title = "Unknown error";
|
this.testing.message = err.statusText;
|
||||||
}
|
this.testing.title = "HTTP Error";
|
||||||
});
|
} else {
|
||||||
}).finally(() => {
|
this.testing.message = err.message;
|
||||||
if (this.testing) {
|
this.testing.title = "Unknown error";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).finally(() => {
|
||||||
this.testing.done = true;
|
this.testing.done = true;
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
vendor/github.com/patrickmn/go-cache/CONTRIBUTORS
generated
vendored
1
vendor/github.com/patrickmn/go-cache/CONTRIBUTORS
generated
vendored
@@ -6,3 +6,4 @@ code was contributed.)
|
|||||||
Dustin Sallings <dustin@spy.net>
|
Dustin Sallings <dustin@spy.net>
|
||||||
Jason Mooberry <jasonmoo@me.com>
|
Jason Mooberry <jasonmoo@me.com>
|
||||||
Sergey Shepelev <temotor@gmail.com>
|
Sergey Shepelev <temotor@gmail.com>
|
||||||
|
Alex Edwards <ajmedwards@gmail.com>
|
||||||
|
|||||||
2
vendor/github.com/patrickmn/go-cache/LICENSE
generated
vendored
2
vendor/github.com/patrickmn/go-cache/LICENSE
generated
vendored
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2012-2016 Patrick Mylund Nielsen and the go-cache contributors
|
Copyright (c) 2012-2017 Patrick Mylund Nielsen and the go-cache contributors
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
126
vendor/github.com/patrickmn/go-cache/README.md
generated
vendored
126
vendor/github.com/patrickmn/go-cache/README.md
generated
vendored
@@ -20,86 +20,62 @@ one) to recover from downtime quickly. (See the docs for `NewFrom()` for caveats
|
|||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/patrickmn/go-cache"
|
"github.com/patrickmn/go-cache"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Create a cache with a default expiration time of 5 minutes, and which
|
||||||
|
// purges expired items every 10 minutes
|
||||||
|
c := cache.New(5*time.Minute, 10*time.Minute)
|
||||||
|
|
||||||
// Create a cache with a default expiration time of 5 minutes, and which
|
// Set the value of the key "foo" to "bar", with the default expiration time
|
||||||
// purges expired items every 30 seconds
|
c.Set("foo", "bar", cache.DefaultExpiration)
|
||||||
c := cache.New(5*time.Minute, 30*time.Second)
|
|
||||||
|
|
||||||
// Set the value of the key "foo" to "bar", with the default expiration time
|
// Set the value of the key "baz" to 42, with no expiration time
|
||||||
c.Set("foo", "bar", cache.DefaultExpiration)
|
// (the item won't be removed until it is re-set, or removed using
|
||||||
|
// c.Delete("baz")
|
||||||
// Set the value of the key "baz" to 42, with no expiration time
|
c.Set("baz", 42, cache.NoExpiration)
|
||||||
// (the item won't be removed until it is re-set, or removed using
|
|
||||||
// c.Delete("baz")
|
|
||||||
c.Set("baz", 42, cache.NoExpiration)
|
|
||||||
|
|
||||||
// Get the string associated with the key "foo" from the cache
|
|
||||||
foo, found := c.Get("foo")
|
|
||||||
if found {
|
|
||||||
fmt.Println(foo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since Go is statically typed, and cache values can be anything, type
|
|
||||||
// assertion is needed when values are being passed to functions that don't
|
|
||||||
// take arbitrary types, (i.e. interface{}). The simplest way to do this for
|
|
||||||
// values which will only be used once--e.g. for passing to another
|
|
||||||
// function--is:
|
|
||||||
foo, found := c.Get("foo")
|
|
||||||
if found {
|
|
||||||
MyFunction(foo.(string))
|
|
||||||
}
|
|
||||||
|
|
||||||
// This gets tedious if the value is used several times in the same function.
|
|
||||||
// You might do either of the following instead:
|
|
||||||
if x, found := c.Get("foo"); found {
|
|
||||||
foo := x.(string)
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
// or
|
|
||||||
var foo string
|
|
||||||
if x, found := c.Get("foo"); found {
|
|
||||||
foo = x.(string)
|
|
||||||
}
|
|
||||||
// ...
|
|
||||||
// foo can then be passed around freely as a string
|
|
||||||
|
|
||||||
// Want performance? Store pointers!
|
|
||||||
c.Set("foo", &MyStruct, cache.DefaultExpiration)
|
|
||||||
if x, found := c.Get("foo"); found {
|
|
||||||
foo := x.(*MyStruct)
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// If you store a reference type like a pointer, slice, map or channel, you
|
|
||||||
// do not need to run Set if you modify the underlying data. The cached
|
|
||||||
// reference points to the same memory, so if you modify a struct whose
|
|
||||||
// pointer you've stored in the cache, retrieving that pointer with Get will
|
|
||||||
// point you to the same data:
|
|
||||||
foo := &MyStruct{Num: 1}
|
|
||||||
c.Set("foo", foo, cache.DefaultExpiration)
|
|
||||||
// ...
|
|
||||||
x, _ := c.Get("foo")
|
|
||||||
foo := x.(*MyStruct)
|
|
||||||
fmt.Println(foo.Num)
|
|
||||||
// ...
|
|
||||||
foo.Num++
|
|
||||||
// ...
|
|
||||||
x, _ := c.Get("foo")
|
|
||||||
foo := x.(*MyStruct)
|
|
||||||
foo.Println(foo.Num)
|
|
||||||
|
|
||||||
// will print:
|
|
||||||
// 1
|
|
||||||
// 2
|
|
||||||
|
|
||||||
|
// Get the string associated with the key "foo" from the cache
|
||||||
|
foo, found := c.Get("foo")
|
||||||
|
if found {
|
||||||
|
fmt.Println(foo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Since Go is statically typed, and cache values can be anything, type
|
||||||
|
// assertion is needed when values are being passed to functions that don't
|
||||||
|
// take arbitrary types, (i.e. interface{}). The simplest way to do this for
|
||||||
|
// values which will only be used once--e.g. for passing to another
|
||||||
|
// function--is:
|
||||||
|
foo, found := c.Get("foo")
|
||||||
|
if found {
|
||||||
|
MyFunction(foo.(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
// This gets tedious if the value is used several times in the same function.
|
||||||
|
// You might do either of the following instead:
|
||||||
|
if x, found := c.Get("foo"); found {
|
||||||
|
foo := x.(string)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
// or
|
||||||
|
var foo string
|
||||||
|
if x, found := c.Get("foo"); found {
|
||||||
|
foo = x.(string)
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
// foo can then be passed around freely as a string
|
||||||
|
|
||||||
|
// Want performance? Store pointers!
|
||||||
|
c.Set("foo", &MyStruct, cache.DefaultExpiration)
|
||||||
|
if x, found := c.Get("foo"); found {
|
||||||
|
foo := x.(*MyStruct)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reference
|
### Reference
|
||||||
|
|||||||
32
vendor/github.com/patrickmn/go-cache/cache.go
generated
vendored
32
vendor/github.com/patrickmn/go-cache/cache.go
generated
vendored
@@ -135,6 +135,36 @@ func (c *cache) Get(k string) (interface{}, bool) {
|
|||||||
return item.Object, true
|
return item.Object, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWithExpiration returns an item and its expiration time from the cache.
|
||||||
|
// It returns the item or nil, the expiration time if one is set (if the item
|
||||||
|
// never expires a zero value for time.Time is returned), and a bool indicating
|
||||||
|
// whether the key was found.
|
||||||
|
func (c *cache) GetWithExpiration(k string) (interface{}, time.Time, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
// "Inlining" of get and Expired
|
||||||
|
item, found := c.items[k]
|
||||||
|
if !found {
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return nil, time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Expiration > 0 {
|
||||||
|
if time.Now().UnixNano() > item.Expiration {
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return nil, time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the item and the expiration time
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return item.Object, time.Unix(0, item.Expiration), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If expiration <= 0 (i.e. no expiration time set) then return the item
|
||||||
|
// and a zeroed time.Time
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return item.Object, time.Time{}, true
|
||||||
|
}
|
||||||
|
|
||||||
func (c *cache) get(k string) (interface{}, bool) {
|
func (c *cache) get(k string) (interface{}, bool) {
|
||||||
item, found := c.items[k]
|
item, found := c.items[k]
|
||||||
if !found {
|
if !found {
|
||||||
@@ -1044,7 +1074,6 @@ type janitor struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (j *janitor) Run(c *cache) {
|
func (j *janitor) Run(c *cache) {
|
||||||
j.stop = make(chan bool)
|
|
||||||
ticker := time.NewTicker(j.Interval)
|
ticker := time.NewTicker(j.Interval)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -1064,6 +1093,7 @@ func stopJanitor(c *Cache) {
|
|||||||
func runJanitor(c *cache, ci time.Duration) {
|
func runJanitor(c *cache, ci time.Duration) {
|
||||||
j := &janitor{
|
j := &janitor{
|
||||||
Interval: ci,
|
Interval: ci,
|
||||||
|
stop: make(chan bool),
|
||||||
}
|
}
|
||||||
c.janitor = j
|
c.janitor = j
|
||||||
go j.Run(c)
|
go j.Run(c)
|
||||||
|
|||||||
6
vendor/vendor.json
vendored
6
vendor/vendor.json
vendored
@@ -455,10 +455,10 @@
|
|||||||
"revisionTime": "2017-07-07T05:36:02Z"
|
"revisionTime": "2017-07-07T05:36:02Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"checksumSHA1": "8z32QKTSDusa4QQyunKE4kyYXZ8=",
|
"checksumSHA1": "JVGDxPn66bpe6xEiexs1r+y6jF0=",
|
||||||
"path": "github.com/patrickmn/go-cache",
|
"path": "github.com/patrickmn/go-cache",
|
||||||
"revision": "e7a9def80f35fe1b170b7b8b68871d59dea117e1",
|
"revision": "a3647f8e31d79543b2d0f0ae2fe5c379d72cedc0",
|
||||||
"revisionTime": "2016-11-25T23:48:19Z"
|
"revisionTime": "2017-07-22T04:01:10Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"checksumSHA1": "SMUvX2B8eoFd9wnPofwBKlN6btE=",
|
"checksumSHA1": "SMUvX2B8eoFd9wnPofwBKlN6btE=",
|
||||||
|
|||||||
Reference in New Issue
Block a user