Fixing several bugs and adding very basic rule support
This commit is contained in:
parent
429ba7e291
commit
a7f7edcd0b
@ -2,11 +2,14 @@ import * as io from "socket.io";
|
|||||||
import { Server } from "http";
|
import { Server } from "http";
|
||||||
import { DatabaseManager } from "./database/database";
|
import { DatabaseManager } from "./database/database";
|
||||||
import Logging from "@hibas123/logging";
|
import Logging from "@hibas123/logging";
|
||||||
|
import Query from "./database/query";
|
||||||
|
import Session from "./database/session";
|
||||||
|
|
||||||
type QueryTypes = "get" | "set" | "push" | "subscribe" | "unsubscribe";
|
type QueryTypes = "get" | "set" | "push" | "subscribe" | "unsubscribe";
|
||||||
|
|
||||||
export class ConnectionManager {
|
export class ConnectionManager {
|
||||||
static server: io.Server;
|
static server: io.Server;
|
||||||
|
|
||||||
static bind(server: Server) {
|
static bind(server: Server) {
|
||||||
this.server = io(server);
|
this.server = io(server);
|
||||||
this.server.on("connection", this.onConnection.bind(this));
|
this.server.on("connection", this.onConnection.bind(this));
|
||||||
@ -14,9 +17,13 @@ export class ConnectionManager {
|
|||||||
|
|
||||||
private static onConnection(socket: io.Socket) {
|
private static onConnection(socket: io.Socket) {
|
||||||
const reqMap = new Map<string, [number, number]>();
|
const reqMap = new Map<string, [number, number]>();
|
||||||
|
const stored = new Map<string, Query>();
|
||||||
|
const session = new Session();
|
||||||
|
|
||||||
const answer = (id: string, data: any, err: boolean = false) => {
|
const answer = (id: string, data: any, err: boolean = false) => {
|
||||||
let time = process.hrtime(reqMap.get(id));
|
let time = process.hrtime(reqMap.get(id));
|
||||||
Logging.debug(`Sending answer for ${id} with data`, data, err ? "as error" : "", "Took", time[1] / 1000, "us");
|
reqMap.delete(id);
|
||||||
|
// Logging.debug(`Sending answer for ${id} with data`, data, err ? "as error" : "", "Took", time[1] / 1000, "us");
|
||||||
socket.emit("message", id, err, data);
|
socket.emit("message", id, err, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,33 +32,48 @@ export class ConnectionManager {
|
|||||||
})
|
})
|
||||||
|
|
||||||
socket.on("query", async (id: string, type: QueryTypes, database: string, path: string[], data: any) => {
|
socket.on("query", async (id: string, type: QueryTypes, database: string, path: string[], data: any) => {
|
||||||
Logging.debug(`Request with id ${id} from type ${type} for database ${database} and path ${path} with data ${data}`)
|
Logging.debug(`Request with id '${id}' from type '${type}' for database '${database}' and path '${path}' with data`, data)
|
||||||
reqMap.set(id, process.hrtime());
|
reqMap.set(id, process.hrtime());
|
||||||
try {
|
try {
|
||||||
const db = DatabaseManager.getDatabase(database);
|
const db = DatabaseManager.getDatabase(database);
|
||||||
|
const perms = db.rules.hasPermission(path, session);
|
||||||
|
const noperm = new Error("No permisison!");
|
||||||
if (!db)
|
if (!db)
|
||||||
answer(id, "Database not found!", true);
|
answer(id, "Database not found!", true);
|
||||||
else {
|
else {
|
||||||
const query = db.getQuery(path);
|
const query = stored.get(id) || db.getQuery(path);
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "get":
|
case "get":
|
||||||
|
if (!perms.read)
|
||||||
|
throw noperm;
|
||||||
answer(id, await query.get());
|
answer(id, await query.get());
|
||||||
break;
|
return;
|
||||||
case "set":
|
case "set":
|
||||||
|
if (!perms.write)
|
||||||
|
throw noperm;
|
||||||
answer(id, await query.set(data));
|
answer(id, await query.set(data));
|
||||||
break;
|
return;
|
||||||
case "push":
|
case "push":
|
||||||
|
if (!perms.write)
|
||||||
|
throw noperm;
|
||||||
answer(id, await query.push(data));
|
answer(id, await query.push(data));
|
||||||
break;
|
return;
|
||||||
case "subscribe":
|
case "subscribe":
|
||||||
answer(id, await query.subscribe());
|
if (!perms.read)
|
||||||
break;
|
throw noperm;
|
||||||
|
query.subscribe(data, (data) => {
|
||||||
|
answer(id, data);
|
||||||
|
});
|
||||||
|
stored.set(id, query);
|
||||||
|
return;
|
||||||
case "unsubscribe":
|
case "unsubscribe":
|
||||||
answer(id, await query.unsubscribe());
|
query.unsubscribe();
|
||||||
break;
|
stored.delete(id);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
answer(id, "Invalid request!", true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logging.error(err);
|
Logging.error(err);
|
||||||
@ -61,7 +83,9 @@ export class ConnectionManager {
|
|||||||
})
|
})
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
|
reqMap.clear();
|
||||||
|
stored.clear();
|
||||||
|
socket.removeAllListeners();
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -3,6 +3,8 @@ import Settings from "../settings";
|
|||||||
import getLevelDB from "../storage";
|
import getLevelDB from "../storage";
|
||||||
import PathLock from "./lock";
|
import PathLock from "./lock";
|
||||||
import Query from "./query";
|
import Query from "./query";
|
||||||
|
import { Observable } from "@hibas123/utils";
|
||||||
|
import Logging from "@hibas123/logging";
|
||||||
|
|
||||||
export class DatabaseManager {
|
export class DatabaseManager {
|
||||||
static databases = new Map<string, Database>();
|
static databases = new Map<string, Database>();
|
||||||
@ -16,10 +18,11 @@ export class DatabaseManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static addDatabase(name: string) {
|
static async addDatabase(name: string) {
|
||||||
if (this.databases.has(name))
|
if (this.databases.has(name))
|
||||||
throw new Error("Database already exists!");
|
throw new Error("Database already exists!");
|
||||||
|
|
||||||
|
await Settings.addDatabase(name);
|
||||||
let database = new Database(name);
|
let database = new Database(name);
|
||||||
this.databases.set(name, database);
|
this.databases.set(name, database);
|
||||||
return database;
|
return database;
|
||||||
@ -39,11 +42,23 @@ export class DatabaseManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ChangeTypes {
|
||||||
|
SET,
|
||||||
|
PUSH
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Change = {
|
||||||
|
type: ChangeTypes;
|
||||||
|
path: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export class Database {
|
export class Database {
|
||||||
public level = getLevelDB(this.name);
|
public level = getLevelDB(this.name);
|
||||||
private rules: Rules;
|
public rules: Rules;
|
||||||
public locks = new PathLock()
|
public locks = new PathLock()
|
||||||
|
|
||||||
|
public changeObservable = new Observable<Change>();
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { Database } from "./database";
|
import { Database, Change, ChangeTypes } from "./database";
|
||||||
|
|
||||||
import Encoder, { DataTypes } from "@hibas123/binary-encoder";
|
import Encoder, { DataTypes } from "@hibas123/binary-encoder";
|
||||||
import { resNull } from "../storage";
|
import { resNull } from "../storage";
|
||||||
import { Bytes } from "leveldown";
|
|
||||||
import { LevelUpChain } from "levelup";
|
import { LevelUpChain } from "levelup";
|
||||||
import shortid = require("shortid");
|
import shortid = require("shortid");
|
||||||
import Logging from "@hibas123/nodelogging";
|
import Logging from "@hibas123/nodelogging";
|
||||||
@ -14,20 +13,14 @@ enum FieldTypes {
|
|||||||
|
|
||||||
interface IField {
|
interface IField {
|
||||||
type: FieldTypes;
|
type: FieldTypes;
|
||||||
// fields?: string[];
|
|
||||||
value?: any
|
value?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const FieldEncoder = new Encoder<IField>({
|
export const FieldEncoder = new Encoder<IField>({
|
||||||
type: {
|
type: {
|
||||||
index: 1,
|
index: 1,
|
||||||
type: DataTypes.UINT8
|
type: DataTypes.UINT8
|
||||||
},
|
},
|
||||||
// fields: {
|
|
||||||
// index: 2,
|
|
||||||
// type: DataTypes.STRING,
|
|
||||||
// array: true
|
|
||||||
// },
|
|
||||||
value: {
|
value: {
|
||||||
index: 3,
|
index: 3,
|
||||||
type: DataTypes.AUTO,
|
type: DataTypes.AUTO,
|
||||||
@ -43,6 +36,8 @@ export default class Query {
|
|||||||
if (path.find(segment => segment.indexOf("/") >= 0)) {
|
if (path.find(segment => segment.indexOf("/") >= 0)) {
|
||||||
throw new Error("Path cannot contain '/'!");
|
throw new Error("Path cannot contain '/'!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onChange = this.onChange.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private pathToKey(path?: string[]) {
|
private pathToKey(path?: string[]) {
|
||||||
@ -90,15 +85,13 @@ export default class Query {
|
|||||||
if (obj.type === FieldTypes.VALUE) {
|
if (obj.type === FieldTypes.VALUE) {
|
||||||
return obj.value;
|
return obj.value;
|
||||||
} else {
|
} else {
|
||||||
let res = {};
|
|
||||||
|
|
||||||
|
|
||||||
let fields = await this.getFields(this.path);
|
let fields = await this.getFields(this.path);
|
||||||
let a = fields.map(field => field.split("/").filter(e => e !== "")).sort((a, b) => a.length - b.length).map(async path => {
|
let sorted = fields.map(field => field.split("/").filter(e => e !== "")).sort((a, b) => a.length - b.length)
|
||||||
let field = await this.getField(path);
|
|
||||||
Logging.debug("Path:", path, "Field:", field);
|
|
||||||
let shortened = path.slice(this.path.length);
|
|
||||||
|
|
||||||
|
let res = {};
|
||||||
|
for (let path of sorted) {
|
||||||
|
let field = await this.getField(path);
|
||||||
|
let shortened = path.slice(this.path.length);
|
||||||
let t = res;
|
let t = res;
|
||||||
for (let section of shortened.slice(0, -1)) {
|
for (let section of shortened.slice(0, -1)) {
|
||||||
t = t[section];
|
t = t[section];
|
||||||
@ -109,9 +102,7 @@ export default class Query {
|
|||||||
} else {
|
} else {
|
||||||
t[path[path.length - 1]] = field.value;
|
t[path[path.length - 1]] = field.value;
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
await Promise.all(a);
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@ -127,10 +118,17 @@ export default class Query {
|
|||||||
let id = shortid.generate();
|
let id = shortid.generate();
|
||||||
let q = new Query(this.database, [...this.path, id]);
|
let q = new Query(this.database, [...this.path, id]);
|
||||||
await q.set(value);
|
await q.set(value);
|
||||||
|
this.database.changeObservable.send({
|
||||||
|
path: [...this.path, id],
|
||||||
|
type: ChangeTypes.PUSH
|
||||||
|
})
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(value: any) {
|
async set(value: any) {
|
||||||
|
if (value === null || value === undefined)
|
||||||
|
return this.delete(value);
|
||||||
|
|
||||||
const lock = await this.database.locks.lock(this.path);
|
const lock = await this.database.locks.lock(this.path);
|
||||||
let batch = this.database.level.batch();
|
let batch = this.database.level.batch();
|
||||||
try {
|
try {
|
||||||
@ -152,7 +150,6 @@ export default class Query {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveValue = (path: string[], value: any) => {
|
const saveValue = (path: string[], value: any) => {
|
||||||
Logging.debug("Save Value:", path, value);
|
|
||||||
if (typeof value === "object") {
|
if (typeof value === "object") {
|
||||||
//TODO: Handle case array!
|
//TODO: Handle case array!
|
||||||
// Field type array?
|
// Field type array?
|
||||||
@ -173,6 +170,10 @@ export default class Query {
|
|||||||
saveValue(this.path, value);
|
saveValue(this.path, value);
|
||||||
|
|
||||||
await batch.write();
|
await batch.write();
|
||||||
|
this.database.changeObservable.send({
|
||||||
|
path: this.path,
|
||||||
|
type: ChangeTypes.SET
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (batch.length > 0)
|
if (batch.length > 0)
|
||||||
batch.clear();
|
batch.clear();
|
||||||
@ -182,7 +183,7 @@ export default class Query {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(batch?: LevelUpChain) {
|
private async delete(batch?: LevelUpChain) {
|
||||||
let lock = batch ? undefined : await this.database.locks.lock(this.path);
|
let lock = batch ? undefined : await this.database.locks.lock(this.path);
|
||||||
const commit = batch ? false : true;
|
const commit = batch ? false : true;
|
||||||
if (!batch)
|
if (!batch)
|
||||||
@ -206,6 +207,54 @@ export default class Query {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async subscribe() { }
|
|
||||||
async unsubscribe() { }
|
subscription: {
|
||||||
|
type: ChangeTypes;
|
||||||
|
send: (data: any) => void;
|
||||||
|
};
|
||||||
|
subscribe(type: "set" | "push", send: (data: any) => void) {
|
||||||
|
this.subscription = {
|
||||||
|
send,
|
||||||
|
type: type === "set" ? ChangeTypes.SET : ChangeTypes.PUSH
|
||||||
|
};
|
||||||
|
this.database.changeObservable.subscribe(this.onChange);
|
||||||
|
Logging.debug("Subscribe");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onChange(change: Change) {
|
||||||
|
Logging.debug("Change:", change);
|
||||||
|
if (!this.subscription)
|
||||||
|
return this.database.changeObservable.unsubscribe(this.onChange);
|
||||||
|
|
||||||
|
const { type, send } = this.subscription;
|
||||||
|
|
||||||
|
if (type === change.type) {
|
||||||
|
Logging.debug("Path", this.path, change.path);
|
||||||
|
if (this.path.length === change.path.length - (type === ChangeTypes.PUSH ? 1 : 0)) {
|
||||||
|
let valid = true;
|
||||||
|
for (let i = 0; i < this.path.length; i++) {
|
||||||
|
if (this.path[i] !== change.path[i]) {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (valid) {
|
||||||
|
Logging.debug("Send Change:", change);
|
||||||
|
if (type === ChangeTypes.PUSH) {
|
||||||
|
send({
|
||||||
|
id: change.path[change.path.length - 1],
|
||||||
|
data: await new Query(this.database, change.path).get()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
send(await this.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe() {
|
||||||
|
this.subscription = undefined;
|
||||||
|
this.database.changeObservable.unsubscribe(this.onChange);
|
||||||
|
}
|
||||||
}
|
}
|
@ -57,7 +57,7 @@ export class Rules {
|
|||||||
this.rules = analyze(parsed);
|
this.rules = analyze(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPermission(path: string[], session: Session) {
|
hasPermission(path: string[], session: Session): { read: boolean, write: boolean } {
|
||||||
let read = this.rules[".read"] || false;
|
let read = this.rules[".read"] || false;
|
||||||
let write = this.rules[".write"] || false;
|
let write = this.rules[".write"] || false;
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ export class Rules {
|
|||||||
|
|
||||||
for (let segment of path) {
|
for (let segment of path) {
|
||||||
rules = rules[segment];
|
rules = rules[segment];
|
||||||
if (rules[segment]) {
|
if (rules) {
|
||||||
if (rules[".read"]) {
|
if (rules[".read"]) {
|
||||||
read = rules[".read"]
|
read = rules[".read"]
|
||||||
}
|
}
|
||||||
@ -79,8 +79,8 @@ export class Rules {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
read,
|
read: read as boolean,
|
||||||
write
|
write: write as boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
export function checkRules(rules: any) {
|
|
||||||
|
|
||||||
}
|
|
@ -3,13 +3,18 @@ import Settings from "../../settings";
|
|||||||
import getForm from "../helper/form";
|
import getForm from "../helper/form";
|
||||||
import Logging from "@hibas123/nodelogging";
|
import Logging from "@hibas123/nodelogging";
|
||||||
import getTable from "../helper/table";
|
import getTable from "../helper/table";
|
||||||
import { BadRequestError } from "../helper/errors";
|
import { BadRequestError, NoPermissionError } from "../helper/errors";
|
||||||
import { DatabaseManager } from "../../database/database";
|
import { DatabaseManager } from "../../database/database";
|
||||||
|
import { FieldEncoder } from "../../database/query";
|
||||||
|
import getTemplate from "../helper/hb";
|
||||||
|
import config from "../../config";
|
||||||
|
|
||||||
const AdminRoute = new Router();
|
const AdminRoute = new Router();
|
||||||
|
|
||||||
AdminRoute.use((ctx, next) => {
|
AdminRoute.use(async (ctx, next) => {
|
||||||
//TODO: Check permission
|
const { key } = ctx.query;
|
||||||
|
if (key !== config.general.admin)
|
||||||
|
throw new NoPermissionError("No permission!");
|
||||||
return next();
|
return next();
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -50,7 +55,7 @@ AdminRoute.get("/data", async ctx => {
|
|||||||
});
|
});
|
||||||
let res = [["key", "value"]];
|
let res = [["key", "value"]];
|
||||||
stream.on("data", ({ key, value }) => {
|
stream.on("data", ({ key, value }) => {
|
||||||
res.push([key, value]);
|
res.push([key, JSON.stringify(FieldEncoder.decode(value))]);
|
||||||
})
|
})
|
||||||
|
|
||||||
stream.on("error", no);
|
stream.on("error", no);
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
<title>{{title}}</title>
|
<title>{{title}}</title>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/@hibas123/theme/out/base.css">
|
<link rel="stylesheet" href="https://unpkg.com/@hibas123/theme/out/base.css">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/@hibas123/theme@1.2.6/out/light.css">
|
<link rel="stylesheet" href="https://unpkg.com/@hibas123/theme/out/light.css">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#message {
|
#message {
|
||||||
@ -29,24 +29,24 @@
|
|||||||
<div id="message"> </div>
|
<div id="message"> </div>
|
||||||
<form id="f1" action="JavaScript:void(null)">
|
<form id="f1" action="JavaScript:void(null)">
|
||||||
{{#each fields}}
|
{{#each fields}}
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>{{label}}</label>
|
<label>{{label}}</label>
|
||||||
{{#ifCond type "===" "text"}}
|
{{#ifCond type "===" "text"}}
|
||||||
<input type="text" placeholder="{{label}}" name="{{name}}" value="{{value}}" />
|
<input type="text" placeholder="{{label}}" name="{{name}}" value="{{value}}" />
|
||||||
{{/ifCond}}
|
{{/ifCond}}
|
||||||
|
|
||||||
{{#ifCond type "===" "number"}}
|
{{#ifCond type "===" "number"}}
|
||||||
<input type="number" placeholder="{{label}}" name="{{name}}" value="{{value}}" />
|
<input type="number" placeholder="{{label}}" name="{{name}}" value="{{value}}" />
|
||||||
{{/ifCond}}
|
{{/ifCond}}
|
||||||
|
|
||||||
{{#ifCond type "===" "boolean"}}
|
{{#ifCond type "===" "boolean"}}
|
||||||
<input type="checkbox" name="{{name}}" checked="{{value}}" />
|
<input type="checkbox" name="{{name}}" checked="{{value}}" />
|
||||||
{{/ifCond}}
|
{{/ifCond}}
|
||||||
|
|
||||||
{{#ifCond type "===" "textarea"}}
|
{{#ifCond type "===" "textarea"}}
|
||||||
<textarea class="inp" name="{{name}}" rows="20">{{value}}</textarea>
|
<textarea class="inp" name="{{name}}" rows="20">{{value}}</textarea>
|
||||||
{{/ifCond}}
|
{{/ifCond}}
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|
||||||
<button class="btn btn-primary" onclick="submitData()">Submit</button>
|
<button class="btn btn-primary" onclick="submitData()">Submit</button>
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
<title>{{title}}</title>
|
<title>{{title}}</title>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/@hibas123/theme/out/base.css">
|
<link rel="stylesheet" href="https://unpkg.com/@hibas123/theme/out/base.css">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/@hibas123/theme@1.2.6/out/light.css">
|
<link rel="stylesheet" href="https://unpkg.com/@hibas123/theme/out/light.css">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
table {
|
table {
|
||||||
@ -37,18 +37,18 @@
|
|||||||
<div class="margin" style="margin-top: 4rem;">
|
<div class="margin" style="margin-top: 4rem;">
|
||||||
<h1>{{title}}</h1>
|
<h1>{{title}}</h1>
|
||||||
{{#if empty}}
|
{{#if empty}}
|
||||||
<h3>No Data available!</h3>
|
<h3>No Data available!</h3>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|
||||||
<table style="overflow-x: auto">
|
<table style="overflow-x: auto">
|
||||||
{{#each table as |row|}}
|
{{#each table as |row|}}
|
||||||
<tr>
|
<tr>
|
||||||
{{#each row as |col|}}
|
{{#each row as |col|}}
|
||||||
<td>{{col}}</td>
|
<td>{{col}}</td>
|
||||||
|
{{/each}}
|
||||||
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</tr>
|
</table>
|
||||||
{{/each}}
|
|
||||||
</table>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user