rozetko
6 years ago
commit
02e4168497
19 changed files with 679 additions and 0 deletions
@ -0,0 +1,6 @@
|
||||
node_modules/ |
||||
dist/ |
||||
exported/*.csv |
||||
exported/*.json |
||||
api-keys.json |
||||
package-lock.json |
@ -0,0 +1,21 @@
|
||||
# Pull node base image |
||||
FROM node:latest |
||||
|
||||
ARG build_path=/var/www |
||||
ENV BUILD_PATH=$build_path |
||||
|
||||
# Expose port 80 |
||||
EXPOSE 80 |
||||
|
||||
VOLUME [ "/var/www/exported" ] |
||||
|
||||
# Copy custom configuration file from the current directory |
||||
|
||||
WORKDIR ${BUILD_PATH} |
||||
COPY . ${BUILD_PATH} |
||||
|
||||
RUN npm install |
||||
RUN npm run build |
||||
|
||||
# Start up node server |
||||
CMD ["npm", "start"] |
@ -0,0 +1,50 @@
|
||||
# Export-manager backend |
||||
|
||||
Server for fetching data from Grafana datasources |
||||
|
||||
Running on 80 port in Docker container. |
||||
|
||||
Dockerfile has 2 volumes: |
||||
|
||||
- `/var/www/api-keys.json` (required, contains API keys for your Grafana hosts, see [example](api-keys-example.json)) |
||||
- `/var/www/exported` (optional, directory which contains exported csv files and info about them) |
||||
|
||||
## Build |
||||
|
||||
``` |
||||
npm install |
||||
npm run build |
||||
``` |
||||
|
||||
## Run |
||||
|
||||
``` |
||||
npm start |
||||
``` |
||||
|
||||
## Development |
||||
|
||||
You should have `nodemon` module installed to run development server. |
||||
|
||||
``` |
||||
npm i -g nodemon |
||||
npm run dev |
||||
``` |
||||
|
||||
## Changelog |
||||
|
||||
### [0.3.1] - 2018-05-14 |
||||
#### Added |
||||
- Show confirmation modal on task delete. |
||||
|
||||
### [0.3.0] - 2018-05-10 |
||||
#### Added |
||||
- Save user that initialized export. |
||||
- Support different grafana URLs. |
||||
- Delete tasks. |
||||
|
||||
### [0.2.0] - 2018-05-09 |
||||
#### Added |
||||
- Fetch data from Grafana API and save it to CSV. |
||||
- Endpoint for showing task status in Simple-JSON datasource format. |
||||
- CSV download URL on task finish. |
@ -0,0 +1,4 @@
|
||||
{ |
||||
"http://grafana.example.com": "eyJrIjoiNFRDNms3Z3RZRmtoU2hKd002dFJUS2FCOHEySUlSZzEiLCJuIjoiZXhwb3J0IiwiaWQiOjF9", |
||||
"http://localhost:3500": "eyJrIjoiTjlUcmtLSFRNcTdqeXBaQjB5REk2TFkyUDBDM0Z1bWciLCJuIjoiZXhwb3J0LW1hbmFnZXIiLCJpZCI6MX0=" |
||||
} |
@ -0,0 +1,10 @@
|
||||
const { spawn } = require('child_process'); |
||||
|
||||
const webpack = spawn('webpack', ['--config', 'build/webpack.dev.conf.js'], { |
||||
stdio: 'inherit', |
||||
shell: true |
||||
}); |
||||
//webpack.stdout.pipe(process.stdout);
|
||||
|
||||
const nodemon = spawn('nodemon', ['../dist/server', '--watch', 'server.js']); |
||||
nodemon.stdout.pipe(process.stdout); |
@ -0,0 +1,52 @@
|
||||
const path = require('path'); |
||||
const fs = require('fs'); |
||||
|
||||
const webpack = require('webpack'); |
||||
|
||||
|
||||
function resolve(p) { |
||||
return path.join(__dirname, '/../', p); |
||||
} |
||||
|
||||
module.exports = { |
||||
target: 'node', |
||||
node: { |
||||
__dirname: false, |
||||
__filename: false, |
||||
}, |
||||
context: resolve('./src'), |
||||
entry: './index', |
||||
devtool: 'inline-source-map', |
||||
output: { |
||||
filename: "server.js", |
||||
path: resolve('dist') |
||||
}, |
||||
externals: [ |
||||
function(context, request, callback) { |
||||
if(request[0] == '.') { |
||||
callback(); |
||||
} else { |
||||
callback(null, "require('" + request + "')"); |
||||
} |
||||
} |
||||
], |
||||
plugins: [ |
||||
new webpack.optimize.OccurrenceOrderPlugin(), |
||||
new webpack.HotModuleReplacementPlugin(), |
||||
new webpack.DefinePlugin({ |
||||
'process.env.NODE_ENV': JSON.stringify('development') |
||||
}) |
||||
], |
||||
resolve: { |
||||
extensions: [".ts", ".js"] |
||||
}, |
||||
module: { |
||||
rules: [ |
||||
{ |
||||
test: /\.ts$/, |
||||
loader: "ts-loader", |
||||
exclude: /node_modules/ |
||||
} |
||||
] |
||||
} |
||||
} |
@ -0,0 +1,4 @@
|
||||
var base = require('./webpack.base.conf'); |
||||
|
||||
base.watch = true; |
||||
module.exports = base; |
@ -0,0 +1,3 @@
|
||||
var base = require('./webpack.base.conf'); |
||||
|
||||
module.exports = base; |
@ -0,0 +1,25 @@
|
||||
{ |
||||
"name": "export-manager-server", |
||||
"version": "0.3.1", |
||||
"description": "Server for fetching data from Grafana datasources", |
||||
"scripts": { |
||||
"start": "node dist/server.js", |
||||
"dev": "node build/dev-server.js", |
||||
"build": "webpack --config build/webpack.prod.conf.js" |
||||
}, |
||||
"author": "CorpGlory", |
||||
"license": "ISC", |
||||
"dependencies": {}, |
||||
"devDependencies": { |
||||
"@types/express": "^4.11.1", |
||||
"axios": "^0.18.0", |
||||
"express": "^4.16.3", |
||||
"fast-csv": "^2.4.1", |
||||
"moment": "^2.22.1", |
||||
"node-fetch": "^2.1.2", |
||||
"nodemon": "^1.17.3", |
||||
"ts-loader": "^3.5.0", |
||||
"typescript": "^2.8.3", |
||||
"webpack": "^3.5.6" |
||||
} |
||||
} |
@ -0,0 +1,16 @@
|
||||
import * as path from 'path'; |
||||
import * as fs from 'fs'; |
||||
|
||||
export const EXPORTED_PATH = path.join(__dirname, '../exported'); |
||||
|
||||
|
||||
export function getApiKey(host) { |
||||
return new Promise((resolve, reject) => { |
||||
fs.readFile(path.join(__dirname, '../api-keys.json'), 'utf8', (err, data) => { |
||||
if(err) { |
||||
reject(err); |
||||
} |
||||
resolve(JSON.parse(data)[host]); |
||||
}); |
||||
}); |
||||
} |
@ -0,0 +1,66 @@
|
||||
import { getApiKey } from './config'; |
||||
|
||||
import axios from 'axios'; |
||||
|
||||
|
||||
export class GrafanaAPI { |
||||
private apiKey; |
||||
constructor(private grafanaUrl) { |
||||
getApiKey(grafanaUrl) |
||||
.then(key => { |
||||
this.apiKey = key; |
||||
console.log(this.apiKey); |
||||
}); |
||||
} |
||||
|
||||
private get _headers() { |
||||
return { |
||||
'Authorization': `Bearer ${this.apiKey}`, |
||||
'Accept': 'application/json', |
||||
'Content-Type': 'application/json' |
||||
}; |
||||
} |
||||
|
||||
private async _getDatasourceByName(name) { |
||||
return fetch(`${this.grafanaUrl}/api/datasources/name/${name}`, { |
||||
method: 'GET', |
||||
headers: this._headers |
||||
}) |
||||
.then(data => data.json()); |
||||
} |
||||
|
||||
public async queryDatasource(datasourceName, measurement, query) { |
||||
let datasource = await this._getDatasourceByName(datasourceName); |
||||
|
||||
return this._queryGrafana(`${this.grafanaUrl}/api/datasources/proxy/${datasource.id}/query`, { |
||||
q: encodeURIComponent(query), |
||||
db: datasource.database, |
||||
epoch: 'ms' |
||||
}); |
||||
} |
||||
|
||||
private async _queryGrafana(url: string, params: any) { |
||||
try { |
||||
var res = await axios.get(url, { params, headers: this._headers }); |
||||
} catch (e) { |
||||
if(e.response.status === 401) { |
||||
throw new Error('Unauthorized. Check the $HASTIC_API_KEY.'); |
||||
} |
||||
throw new Error(e.message); |
||||
} |
||||
|
||||
if (res.data.results === undefined) { |
||||
throw new Error('results field is undefined in response.'); |
||||
} |
||||
|
||||
// TODO: support more than 1 metric (each res.data.results item is a metric)
|
||||
let results = res.data.results[0]; |
||||
if (results.series === undefined) { |
||||
return []; |
||||
} |
||||
|
||||
return results.series[0].values as [number, number][]; |
||||
} |
||||
|
||||
} |
||||
|
@ -0,0 +1,88 @@
|
||||
export type GrafanaDatasource = { |
||||
url: string, |
||||
type: string, |
||||
params: { |
||||
db: string, |
||||
q: string, |
||||
epoch: string |
||||
} |
||||
} |
||||
|
||||
export type GrafanaMetricId = string; |
||||
|
||||
export class GrafanaMetric { |
||||
|
||||
private _metricQuery: MetricQuery = undefined; |
||||
|
||||
constructor( |
||||
public datasource: GrafanaDatasource, |
||||
public targets: any[], |
||||
public id?: GrafanaMetricId |
||||
) { |
||||
if(datasource === undefined) { |
||||
throw new Error('datasource is undefined'); |
||||
} |
||||
if(targets === undefined) { |
||||
throw new Error('targets is undefined'); |
||||
} |
||||
if(targets.length === 0) { |
||||
throw new Error('targets is empty'); |
||||
} |
||||
} |
||||
|
||||
public get metricQuery() { |
||||
if(this._metricQuery === undefined) { |
||||
this._metricQuery = new MetricQuery(this); |
||||
} |
||||
return this._metricQuery; |
||||
} |
||||
|
||||
public toObject() { |
||||
return { |
||||
datasource: this.datasource, |
||||
targets: this.targets, |
||||
_id: this.id |
||||
}; |
||||
} |
||||
|
||||
static fromObject(obj: any): GrafanaMetric { |
||||
if(obj === undefined) { |
||||
throw new Error('obj is undefined'); |
||||
} |
||||
return new GrafanaMetric( |
||||
obj.datasource, |
||||
obj.targets, |
||||
obj._id |
||||
); |
||||
} |
||||
} |
||||
|
||||
export class MetricQuery { |
||||
|
||||
private static INFLUX_QUERY_TIME_REGEX = /time >[^A-Z]+/; |
||||
|
||||
private _queryParts: string[]; |
||||
private _type: string; |
||||
|
||||
constructor(metric: GrafanaMetric) { |
||||
this._type = metric.datasource.type; |
||||
if (this._type !== 'influxdb') { |
||||
throw new Error(`Queries of type "${metric.datasource.type}" are not supported yet.`); |
||||
} |
||||
var queryStr = metric.datasource.params.q; |
||||
this._queryParts = queryStr.split(MetricQuery.INFLUX_QUERY_TIME_REGEX); |
||||
if(this._queryParts.length == 1) { |
||||
throw new Error( |
||||
`Query "${queryStr}" is not replaced with LIMIT/OFFSET oeprators. Missing time clause.` |
||||
); |
||||
} |
||||
if(this._queryParts.length > 2) { |
||||
throw new Error(`Query "${queryStr}" has multiple time clauses. Can't parse.`); |
||||
} |
||||
} |
||||
|
||||
getQuery(from: number, to: number, limit: number, offset: number): string { |
||||
let timeClause = `time >= ${from}ms AND time <= ${to}ms`; |
||||
return `${this._queryParts[0]} ${timeClause} ${this._queryParts[1]} LIMIT ${limit} OFFSET ${offset}`; |
||||
} |
||||
} |
@ -0,0 +1,31 @@
|
||||
import { EXPORTED_PATH } from './config'; |
||||
import { router as tasksRouter } from './routes/tasks'; |
||||
import { router as datasourceRouter } from './routes/datasource'; |
||||
import { router as deleteRouter } from './routes/delete'; |
||||
|
||||
import * as express from 'express'; |
||||
import * as bodyParser from 'body-parser'; |
||||
|
||||
const app = express(); |
||||
const PORT = 80; |
||||
|
||||
app.use(bodyParser.json()); |
||||
app.use(bodyParser.urlencoded({ extended: true })); |
||||
|
||||
app.use(function(req, res, next) { |
||||
res.header('Access-Control-Allow-Origin', '*'); |
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); |
||||
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); |
||||
next(); |
||||
}) |
||||
|
||||
app.use('/tasks', tasksRouter); |
||||
app.use('/datasource', datasourceRouter); |
||||
app.use('/delete', deleteRouter); |
||||
|
||||
app.use('/static', express.static(EXPORTED_PATH)); |
||||
app.use('/', (req, res) => { res.send('Export-manager backend server works') }); |
||||
|
||||
app.listen(PORT, () => { |
||||
console.log(`Server is running on :${PORT}`); |
||||
}) |
@ -0,0 +1,123 @@
|
||||
import { EXPORTED_PATH } from '../config'; |
||||
|
||||
import * as express from 'express'; |
||||
import * as path from 'path'; |
||||
import * as fs from 'fs'; |
||||
|
||||
|
||||
function sendOk(req, res) { |
||||
res.status(200).send('Datasource works'); |
||||
} |
||||
|
||||
function search(req, res) { |
||||
fs.readdir(EXPORTED_PATH, (err, items) => { |
||||
if(err) { |
||||
console.error(err); |
||||
res.status(500).send('Something went wrong'); |
||||
} else { |
||||
res.status(200).send(['All tasks']); |
||||
} |
||||
}) |
||||
} |
||||
|
||||
function query(req, res) { |
||||
let body = req.body; |
||||
let targets = body.targets; |
||||
|
||||
let resp = { |
||||
target: targets[0].target, |
||||
type: 'table', |
||||
columns: [ |
||||
{ |
||||
text: 'Time', |
||||
type: 'time' |
||||
}, |
||||
{ |
||||
text: 'User', |
||||
type: 'string' |
||||
}, |
||||
{ |
||||
text: 'Datasource', |
||||
type: 'string' |
||||
}, |
||||
{ |
||||
text: 'Measurement', |
||||
type: 'string' |
||||
}, |
||||
{ |
||||
text: 'Exported Rows', |
||||
type: 'number' |
||||
}, |
||||
{ |
||||
text: 'Progress', |
||||
type: 'string' |
||||
}, |
||||
{ |
||||
text: 'Download CSV', |
||||
type: 'string' |
||||
}, |
||||
{ |
||||
text: 'Status', |
||||
type: 'string' |
||||
}, |
||||
{ |
||||
text: 'Delete task', |
||||
type: 'string' |
||||
} |
||||
], |
||||
rows: [] |
||||
}; |
||||
|
||||
for(let target of targets) { |
||||
fs.readdir(EXPORTED_PATH, (err, items) => { |
||||
if(err) { |
||||
console.error(err); |
||||
res.status(500).send('Something went wrong'); |
||||
} else { |
||||
for(let item of items) { |
||||
let file = path.parse(item); |
||||
if(file.ext !== '.json') { |
||||
continue; |
||||
} |
||||
// TODO: read async
|
||||
let data = fs.readFileSync(path.join(EXPORTED_PATH, item), 'utf8') |
||||
let status = JSON.parse(data) |
||||
|
||||
let requestedUrl = `http://${req.headers.host}`; |
||||
let downloadLink = ''; |
||||
let deleteLink = ''; |
||||
if(status.status === 'finished') { |
||||
downloadLink = `<a class="download-csv" href="${requestedUrl}/static/${file.name}.csv" target="_blank"><i class="fa fa-download"></i></a>`; |
||||
deleteLink = `<a class="delete-task" href="${requestedUrl}/delete?filename=${file.name}"><i class="fa fa-times"></i></a>`; |
||||
} |
||||
resp.rows.push([ |
||||
status.time, |
||||
status.user, |
||||
status.datasource, |
||||
status.measurement, |
||||
status.exportedRows, |
||||
status.progress, |
||||
downloadLink, |
||||
status.status, |
||||
deleteLink |
||||
]); |
||||
} |
||||
|
||||
res.status(200).send([resp]); |
||||
} |
||||
}) |
||||
} |
||||
|
||||
} |
||||
|
||||
function sendAnnotations(req, res) { |
||||
res.status(200).send([]); |
||||
} |
||||
|
||||
export const router = express.Router(); |
||||
|
||||
router.get('/', sendOk); |
||||
router.post('/search', search); |
||||
router.post('/query', query); |
||||
router.post('/annotations', sendAnnotations); |
||||
|
@ -0,0 +1,25 @@
|
||||
import { EXPORTED_PATH } from '../config' |
||||
|
||||
import * as express from 'express' |
||||
import * as fs from 'fs' |
||||
import * as path from 'path' |
||||
|
||||
|
||||
async function deleteTask(req, res) { |
||||
let filename = req.query.filename; |
||||
let csvFilePath = path.join(EXPORTED_PATH, `${filename}.csv`); |
||||
let jsonFilePath = path.join(EXPORTED_PATH, `${filename}.json`); |
||||
|
||||
if(fs.existsSync(csvFilePath)) { |
||||
fs.unlink(csvFilePath, err => console.error(err)); |
||||
} |
||||
if(fs.existsSync(jsonFilePath)) { |
||||
fs.unlink(jsonFilePath, err => console.error(err)); |
||||
} |
||||
|
||||
res.status(200).send({ status: 'OK' }); |
||||
} |
||||
|
||||
export const router = express.Router(); |
||||
|
||||
router.get('/', deleteTask); |
@ -0,0 +1,25 @@
|
||||
import { Target } from '../target' |
||||
|
||||
import * as express from 'express' |
||||
|
||||
|
||||
async function addTask(req, res) { |
||||
let body = req.body; |
||||
let from = parseInt(body.from); |
||||
let to = parseInt(body.to); |
||||
|
||||
if(isNaN(from) || isNaN(to)) { |
||||
res.status(500).send('Range error: please fill both "from" and "to" fields'); |
||||
} else if(from >= to) { |
||||
res.status(500).send('Range error: "from" should be less than "to"'); |
||||
} else { |
||||
res.status(200).send('Task added'); |
||||
let grafanaUrl = req.get('origin'); |
||||
let target = new Target(grafanaUrl, body.user, body.datasource, body.measurement, body.query, from, to); |
||||
target.export(); |
||||
} |
||||
} |
||||
|
||||
export const router = express.Router(); |
||||
|
||||
router.post('/', addTask); |
@ -0,0 +1,120 @@
|
||||
import { GrafanaAPI } from './grafana_api'; |
||||
|
||||
import * as csv from 'fast-csv'; |
||||
import * as path from 'path'; |
||||
import * as fs from 'fs'; |
||||
import * as moment from 'moment'; |
||||
|
||||
|
||||
const MS_IN_DAY = 24 * 60 * 60 * 1000; |
||||
|
||||
export class Target { |
||||
private exportedRows: number; |
||||
private days: number; |
||||
private day: number; |
||||
private csvStream: any; |
||||
private grafana: GrafanaAPI; |
||||
|
||||
constructor( |
||||
private grafanaUrl: string, |
||||
private user: string, |
||||
private datasource: string, |
||||
private measurement: string, |
||||
private query: string, |
||||
private from: number, |
||||
private to: number |
||||
) { |
||||
this.grafana = new GrafanaAPI(this.grafanaUrl); |
||||
} |
||||
|
||||
public updateStatus(status) { |
||||
let time = moment().valueOf(); |
||||
let data = { |
||||
time, |
||||
user: this.user, |
||||
datasource: this.datasource, |
||||
measurement: this.measurement, |
||||
exportedRows: this.exportedRows, |
||||
progress: (this.day / this.days).toLocaleString('en', { style: 'percent' }), |
||||
status |
||||
}; |
||||
return new Promise((resolve, reject) => { |
||||
fs.writeFile(this.getFilePath('json'), JSON.stringify(data), 'utf8', err => { |
||||
if(err) { |
||||
console.error(err); |
||||
reject('Can`t write file'); |
||||
} else { |
||||
resolve(); |
||||
} |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
public async export() { |
||||
this.exportedRows = 0; |
||||
this.days = (this.to - this.from) / MS_IN_DAY; |
||||
this.day = 0; |
||||
this.initCsvStream(); |
||||
|
||||
let to = this.to; |
||||
let from = this.from; |
||||
|
||||
console.log(`Total days: ${this.days}`); |
||||
while(this.day < this.days) { |
||||
this.day++; |
||||
to = from + MS_IN_DAY; |
||||
|
||||
console.log(`${this.day} day: ${from}ms -> ${to}ms`); |
||||
|
||||
let currentQuery = this.query.replace('$timeFilter', `time >= ${from}ms AND time <= ${to}ms`).replace('$__interval', '1s'); |
||||
let metrics = await this.grafana.queryDatasource(this.datasource, this.measurement, currentQuery); |
||||
|
||||
console.log(metrics); |
||||
if(metrics.length > 0) { |
||||
if(metrics !== undefined) { |
||||
this.writeCsv(metrics); |
||||
} |
||||
} |
||||
await this.updateStatus('exporting'); |
||||
|
||||
from += MS_IN_DAY; |
||||
} |
||||
this.csvStream.end(); |
||||
} |
||||
|
||||
private initCsvStream() { |
||||
this.csvStream = csv.createWriteStream({ headers: true }); |
||||
let writableStream = fs.createWriteStream(this.getFilePath('csv')); |
||||
|
||||
this.csvStream.pipe(writableStream); |
||||
writableStream.on('finish', async () => { |
||||
console.log('Everything is written'); |
||||
await this.updateStatus('finished'); |
||||
}) |
||||
} |
||||
|
||||
private writeCsv(series) { |
||||
for(let serie of series) { |
||||
for(let val of serie.values) { |
||||
if(val[1] !== null) { |
||||
let row = {}; |
||||
for(let col in serie.columns) { |
||||
row[serie.columns[col]] = val[col]; |
||||
} |
||||
this.csvStream.write(row); |
||||
this.exportedRows++; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private getFilename(extension) { |
||||
return `${this.datasource}.${this.measurement}.${this.from}-${this.to}.${extension}`; |
||||
} |
||||
|
||||
private getFilePath(extension) { |
||||
let filename = this.getFilename(extension); |
||||
return path.join(__dirname, `../exported/${filename}`); |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue