Simple Data Viewer

- Generic CDS service to fetch data
- Simple Vue.js UI
This commit is contained in:
Christian Georgi
2022-01-11 21:16:41 +01:00
committed by Christian Georgi
parent 50791bed80
commit 1a71a6d28a
12 changed files with 269 additions and 4 deletions

View File

@@ -22,6 +22,7 @@
"rules": {
"no-console": "off",
"require-atomic-updates": "off",
"require-await":"warn"
"require-await":"warn",
"no-unused-vars": ["warn", { "argsIgnorePattern": "_" }]
}
}

View File

@@ -6,6 +6,7 @@
"@capire/reviews": "*",
"@capire/orders": "*",
"@capire/common": "*",
"@capire/data-viewer": "*",
"@sap/cds": "^5",
"express": "^4.17.1"
},

View File

@@ -8,6 +8,7 @@ cds.once('bootstrap',(app)=>{
app.serve ('/bookshop') .from ('@capire/bookshop','app/vue')
app.serve ('/reviews') .from ('@capire/reviews','app/vue')
app.serve ('/orders') .from('@capire/orders','app/orders')
app.serve ('/data') .from('@capire/data-viewer','app/viewer')
})
// Add Swagger UI

View File

@@ -30,3 +30,6 @@ extend Orders with {
// Add orders fiori app (in case of embedded orders service)
using from '@capire/orders/app/fiori';
// Add data browser
using from '@capire/data-viewer';

View File

@@ -0,0 +1,72 @@
/* global Vue axios */ //> from vue.html
const GET = (url) => axios.get('/-data'+url)
const storageGet = (key, def) => localStorage.getItem('data-viewer:'+key) || def
const storageSet = (key, val) => localStorage.setItem('data-viewer:'+key, val)
const viewer = new Vue ({
el:'#app',
data: {
dataSource: storageGet('data-source', 'db'),
skip: storageGet('skip', 0),
top: storageGet('top', 20),
entity: storageGet('entity') ? JSON.parse(storageGet('entity')) : undefined,
entities: [],
columns: [],
data: [],
rowDetails: undefined,
},
watch: {
dataSource: (v) => { storageSet('data-source', v); viewer.fetchEntities() },
skip: (v) => { storageSet('skip', v); if (viewer.entity) viewer.fetchData() },
top: (v) => { storageSet('top', v); if (viewer.entity) viewer.fetchData() },
},
methods: {
async fetchEntities () {
let url = `/Entities`
if (viewer.dataSource === 'db') url += `?dataSource=db`
const {data} = await GET(url)
viewer.entities = data.value
const entity = viewer.entity && viewer.entities.find(e => e.name === viewer.entity.name)
if (entity) { // restore selection from previous fetch
viewer.columns = entity.columns
await viewer.fetchData(entity)
} else {
viewer.entity = undefined
viewer.columns = []
viewer.data = []
viewer.rowDetails = {}
}
},
async inspectEntity (eve) {
const entity = viewer.entity = viewer.entities [eve.currentTarget.rowIndex-1]
storageSet('entity', JSON.stringify(entity))
viewer.columns = viewer.entities.find(e => e.name === entity.name).columns
return await this.fetchData()
},
async fetchData () {
let url = `/Data?entity=${viewer.entity.name}&$skip=${viewer.skip}&$top=${viewer.top}`
if (viewer.dataSource === 'db') url += `&dataSource=db`
const {data} = await GET(url)
viewer.data = data.value.map(d => d.record.map(r => r.data))
viewer.rowDetails = undefined
},
inspectRow (eve) {
viewer.rowDetails = {}
const selectedRow = eve.currentTarget.rowIndex-1
viewer.data[selectedRow].forEach((line, colIndex) => {
viewer.rowDetails[viewer.columns[colIndex].name] = line
})
},
}
})
viewer.fetchEntities()

View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html>
<head>
<title>Data Browser</title>
<link rel="stylesheet" href="https://unpkg.com/primitive-ui/dist/css/main.css">
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<style>
.hovering tr:hover td { background: #ebeefc; cursor: pointer; }
.highlight { background: #ebeefc; }
.rating-stars { color:teal }
.succeeded { color:teal }
.failed { color:red }
.condensed { max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.key { font-weight: bold }
.not-key { font-weight: lighter;}
.with-sidebar { display: flex; flex-wrap: wrap; gap: 1rem; }
.sidebar { flex-basis: 20rem; flex-grow: 1; }
.not-sidebar { flex-basis: 0; flex-grow: 999; min-inline-size: 50%;}
.horizontal label { display: inline; }
.horizontal input { width: initial; display: inline; }
</style>
</head>
<body>
<div id='app' class="container">
<h1> {{ document.title }}{{ entity ? ' &ndash; ' + entity.name : '' }}</h1>
<div class="with-sidebar">
<div class="sidebar">
<div class="horizontal" style="padding: 0.75rem 0;">
<label>Datasource:</label>
<input type="radio" id="dataSource-db" value="db" v-model="dataSource">
<label for="dataSource-db">Database</label>
<input type="radio" id="dataSource-srv" value="service" v-model="dataSource">
<label for="dataSource-srv">Service</label>
</div>
<table id='entities' class="hovering">
<thead>
<th>Entity Name</th>
</thead>
<tr v-for="e in entities" v-bind:id="e.name" v-on:click="inspectEntity" :class="{'highlight': (entity && e.name === entity.name)}">
<td>{{ e.name }}</td>
</tr>
</table>
</div>
<div class="not-sidebar">
<div class="horizontal">
<label for="skip">Skip:</label>
<input id="skip" v-model.lazy="skip" title="No. of entries to skip" type="number" min="0">
<label for="top">Top:</label>
<input id="top" v-model.lazy="top" title="No. of entries to read" type="number" min="0">
</div>
<div v-if="entity">
<table id='data' class="hovering striped-table condensed">
<thead>
<th v-for="col in columns" v-bind:title="col.type" :class="[col.isKey ? 'key' : 'not-key']">{{ col.name }} </th>
</thead>
<tr v-for="(row, index) in data" v-on:click="inspectRow">
<td v-for="d in row" v-bind:title="d">{{ d }}</td>
</tr>
</table>
</div>
<div v-if="rowDetails">
<table id='rowDetails'>
<tr v-for="(key, value) in rowDetails" >
<td class="key">{{ value }}</td>
<td>{{ key }}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</body>
<script src="app.js"></script>
</html>

1
data-viewer/index.cds Normal file
View File

@@ -0,0 +1 @@
using from './srv/data-service';

13
data-viewer/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "@capire/data-viewer",
"version": "0.1.0",
"description": "A generic browser for data",
"dependencies": {
"@sap/cds": "^5.0.4"
},
"files": [
"app",
"srv",
"index.cds"
]
}

View File

@@ -0,0 +1,29 @@
/**
* Exposes data + entity metadata
*/
//@requires:'admin'
service DataService @( path:'-data' ) {
/**
* Metadata like name and columns/elements
*/
entity Entities {
key name : String;
columns: Composition of many {
name : String;
type : String;
isKey: Boolean;
}
}
/**
* The actual data, organized by column name
*/
entity Data {
record : array of {
column : String;
data : String;
}
}
}

View File

@@ -0,0 +1,58 @@
const cds = require('@sap/cds')
const log = cds.log('data')
class DataService extends cds.ApplicationService { init(){
this.on ('READ', 'Entities', req => {
const { dataSource } = req.req.query
const srvPrefixes = cds.db.model.all('service').map(srv => srv.name+'.')
const dataSourceFilter = dataSource === 'db'
? e => e['@cds.persistence.skip'] !== true // for DB, excl. entities w/o persistence
: e => !!srvPrefixes.find(srvName => e.name.startsWith(srvName)) // only entities reachable from a service
return cds.db.model.all('entity')
.filter (e => req.data && req.data.name ? e.name === req.data.name : true) // honor name filter from request, if any
.filter (e => !e.name.startsWith('DRAFT.')) // exclude synthetic stuff
.filter (e => !e.name.startsWith('DataService.')) // exclude this service
.filter (dataSourceFilter)
.sort((e1, e2) => e1.name.localeCompare(e2.name))
.map(e => {
const columns = Object.entries(e.elements)
.filter(([_, el]) => !(el instanceof cds.Association)) // exclude assocs+compositions
.map(([name, el]) => { return { name, type: el.type, isKey:!!el.key }})
return { name: e.name, columns }
})
})
this.on ('READ', 'Data', async req => {
const { entity: entityName, dataSource: dataSourceName } = req.req.query
if (!entityName) return req.reject(400, `Must provide 'entity' query`)
const entity = cds.db.model.definitions[entityName]
if (!entity) return req.reject(404, 'No such entity: ' + entityName)
const query = SELECT.from(entity)
query.SELECT.limit = req.query.SELECT.limit // use $skip / $top from request
const dataSource = findDataSource(dataSourceName, entityName)
const res = await dataSource.run(query)
return res.map((line) => {
const record = Object.entries(line).map(([column, data]) => {return {column, data}})
return { record }
})
})
return super.init()
}}
module.exports = { DataService }
function findDataSource(dataSourceName, entityName) {
for (let srv of Object.values(cds.services)) { // all connected services
if (!srv.name) continue // FIXME intermediate/pending in cds.services ?
if (dataSourceName === srv.name || entityName.startsWith(srv.name+'.')) {
log._debug && log.debug(`using ${srv.name} as data source`)
return srv
}
}
return cds.services.db // fallback
}

View File

@@ -8,6 +8,7 @@
"@capire/bookstore": "./bookstore",
"@capire/bookshop": "./bookshop",
"@capire/common": "./common",
"@capire/data-viewer": "./data-viewer",
"@capire/fiori": "./fiori",
"@capire/hello": "./hello",
"@capire/media": "./media",

View File

@@ -58,9 +58,11 @@ Each sub directory essentially is an individual npm package arranged in an [all-
- [@capire/reviews](reviews)
- [@capire/orders](orders)
- [@capire/common](common)
- [The Vue.js app](bookshop/app/vue) imported from bookshop is served as well
- [The Vue.js app](reviews/app/vue) imported from reviews is served as well
- [The Fiori app](orders/app) imported from orders is served as well
- [@capire/data-viewer](data-viewer)
- [The Vue.js app](bookshop/app/vue) imported from `bookshop` is served as well
- [The Vue.js app](reviews/app/vue) imported from `reviews` is served as well
- [The Vue.js app](data-viewer/app/data) imported from `data-viewer` is served as well
- [The Fiori app](orders/app) imported from `orders` is served as well
- [OpenAPI export + Swagger UI](https://cap.cloud.sap/docs/advanced/openapi)