Add the shell for configuring IRC bridges

This commit is contained in:
Travis Ralston 2018-03-30 19:22:15 -06:00
parent bd03db7674
commit 76931819af
27 changed files with 509 additions and 9 deletions

View File

@ -6,6 +6,7 @@ import { WidgetStore } from "../../db/WidgetStore";
import { Cache, CACHE_INTEGRATIONS } from "../../MemoryCache";
import { Integration } from "../../integrations/Integration";
import { LogService } from "matrix-js-snippets";
import { BridgeStore } from "../../db/BridgeStore";
interface SetEnabledRequest {
enabled: boolean;
@ -42,6 +43,7 @@ export class AdminIntegrationsService {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
if (category === "widget") await WidgetStore.setEnabled(type, body.enabled);
else if (category === "bridge") await BridgeStore.setEnabled(type, body.enabled);
else throw new ApiError(400, "Unrecognized category");
LogService.info("AdminIntegrationsService", userId + " set " + category + "/" + type + " to " + (body.enabled ? "enabled" : "disabled"));
@ -51,10 +53,11 @@ export class AdminIntegrationsService {
@GET
@Path(":category/all")
public async getAllIntegrations(@QueryParam("scalar_token") scalarToken: string, @QueryParam("category") category: string): Promise<Integration[]> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
public async getAllIntegrations(@QueryParam("scalar_token") scalarToken: string, @PathParam("category") category: string): Promise<Integration[]> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
if (category === "widget") return await DimensionIntegrationsService.getWidgets(false);
else if (category === "bridge") return await DimensionIntegrationsService.getBridges(false, userId);
else throw new ApiError(400, "Unrecongized category");
}
}

View File

@ -8,11 +8,14 @@ import { WidgetStore } from "../../db/WidgetStore";
import { SimpleBot } from "../../integrations/SimpleBot";
import { NebStore } from "../../db/NebStore";
import { ComplexBot } from "../../integrations/ComplexBot";
import { Bridge } from "../../integrations/Bridge";
import { BridgeStore } from "../../db/BridgeStore";
export interface IntegrationsResponse {
widgets: Widget[],
bots: SimpleBot[],
complexBots: ComplexBot[],
bridges: Bridge[],
}
/**
@ -35,6 +38,24 @@ export class DimensionIntegrationsService {
return widgets;
}
/**
* Gets a list of bridges
* @param {boolean} enabledOnly True to only return the enabled bridges
* @param {string} forUserId The requesting user ID
* @param {string} inRoomId If specified, the room ID to list the bridges in
* @returns {Promise<Bridge[]>} Resolves to the bridge list
*/
public static async getBridges(enabledOnly: boolean, forUserId: string, inRoomId?: string): Promise<Bridge[]> {
const cacheKey = inRoomId ? "bridges_" + inRoomId : "bridges";
const cached = Cache.for(CACHE_INTEGRATIONS).get(cacheKey);
if (cached) return cached;
const bridges = await BridgeStore.listAll(forUserId, enabledOnly ? true : null, inRoomId);
Cache.for(CACHE_INTEGRATIONS).put(cacheKey, bridges);
return bridges;
}
/**
* Gets a list of simple bots
* @param {string} userId The requesting user ID
@ -72,6 +93,7 @@ export class DimensionIntegrationsService {
widgets: await DimensionIntegrationsService.getWidgets(true),
bots: await DimensionIntegrationsService.getSimpleBots(userId),
complexBots: await DimensionIntegrationsService.getComplexBots(userId, roomId),
bridges: await DimensionIntegrationsService.getBridges(true, userId, roomId),
};
}
@ -83,6 +105,7 @@ export class DimensionIntegrationsService {
if (category === "widget") return roomConfig.widgets.find(i => i.type === integrationType);
else if (category === "bot") return roomConfig.bots.find(i => i.type === integrationType);
else if (category === "complex-bot") return roomConfig.complexBots.find(i => i.type === integrationType);
else if (category === "bridge") return roomConfig.bridges.find(i => i.type === integrationType);
else throw new ApiError(400, "Unrecognized category");
}
@ -92,6 +115,7 @@ export class DimensionIntegrationsService {
const userId = await ScalarService.getTokenOwner(scalarToken);
if (category === "complex-bot") await NebStore.setComplexBotConfig(userId, integrationType, roomId, newConfig);
else if (category === "bridge") await BridgeStore.setBridgeRoomConfig(userId, integrationType, roomId, newConfig);
else throw new ApiError(400, "Unrecognized category");
Cache.for(CACHE_INTEGRATIONS).clear(); // TODO: Improve which cache we invalidate
@ -106,6 +130,7 @@ export class DimensionIntegrationsService {
if (category === "widget") throw new ApiError(400, "Widgets should be removed client-side");
else if (category === "bot") await NebStore.removeSimpleBot(integrationType, roomId, userId);
else if (category === "complex-bot") throw new ApiError(400, "Complex bots should be removed automatically");
else if (category === "bridge") throw new ApiError(400, "Bridges should be removed automatically");
else throw new ApiError(400, "Unrecognized category");
Cache.for(CACHE_INTEGRATIONS).clear(); // TODO: Improve which cache we invalidate

19
src/bridges/IrcBridge.ts Normal file
View File

@ -0,0 +1,19 @@
import BridgeRecord from "../db/models/BridgeRecord";
import { IrcBridgeConfiguration } from "../integrations/Bridge";
export class IrcBridge {
constructor(private bridgeRecord: BridgeRecord) {
}
public async hasNetworks(): Promise<boolean> {
return !!this.bridgeRecord;
}
public async getRoomConfiguration(requestingUserId: string, inRoomId: string): Promise<IrcBridgeConfiguration> {
return <any>{requestingUserId, inRoomId};
}
public async setRoomConfiguration(requestingUserId: string, inRoomId: string, newConfig: IrcBridgeConfiguration): Promise<any> {
return <any>{requestingUserId, inRoomId, newConfig};
}
}

66
src/db/BridgeStore.ts Normal file
View File

@ -0,0 +1,66 @@
import { Bridge } from "../integrations/Bridge";
import BridgeRecord from "./models/BridgeRecord";
import { IrcBridge } from "../bridges/IrcBridge";
export class BridgeStore {
public static async listAll(requestingUserId: string, isEnabled?: boolean, inRoomId?: string): Promise<Bridge[]> {
let conditions = {};
if (isEnabled === true || isEnabled === false) conditions = {where: {isEnabled: isEnabled}};
const allRecords = await BridgeRecord.findAll(conditions);
const enabledBridges: Bridge[] = [];
for (const bridgeRecord of allRecords) {
if (isEnabled === true || isEnabled === false) {
const isLogicallyEnabled = await BridgeStore.isLogicallyEnabled(bridgeRecord);
if (isLogicallyEnabled !== isEnabled) continue;
}
const bridgeConfig = await BridgeStore.getConfiguration(bridgeRecord, requestingUserId, inRoomId);
enabledBridges.push(new Bridge(bridgeRecord, bridgeConfig));
}
return enabledBridges;
}
public static async setEnabled(type: string, isEnabled: boolean): Promise<any> {
const bridge = await BridgeRecord.findOne({where: {type: type}});
if (!bridge) throw new Error("Bridge not found");
bridge.isEnabled = isEnabled;
return bridge.save();
}
public static async setBridgeRoomConfig(requestingUserId: string, integrationType: string, inRoomId: string, newConfig: any): Promise<any> {
console.log(requestingUserId);
console.log(inRoomId);
console.log(newConfig);
const record = await BridgeRecord.findOne({where: {type: integrationType}});
if (!record) throw new Error("Bridge not found");
if (integrationType === "irc") {
const irc = new IrcBridge(record);
return irc.setRoomConfiguration(requestingUserId, inRoomId, newConfig);
} else throw new Error("Unsupported bridge");
}
private static async isLogicallyEnabled(record: BridgeRecord): Promise<boolean> {
if (record.type === "irc") {
const irc = new IrcBridge(record);
return irc.hasNetworks();
} else return true;
}
private static async getConfiguration(record: BridgeRecord, requestingUserId: string, inRoomId?: string): Promise<any> {
if (record.type === "irc") {
if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs
const irc = new IrcBridge(record);
return irc.getRoomConfiguration(requestingUserId, inRoomId);
} else return {};
}
private constructor() {
}
}

View File

@ -15,6 +15,7 @@ import NebBotUser from "./models/NebBotUser";
import NebNotificationUser from "./models/NebNotificationUser";
import NebIntegrationConfig from "./models/NebIntegrationConfig";
import Webhook from "./models/Webhook";
import BridgeRecord from "./models/BridgeRecord";
class _DimensionStore {
private sequelize: Sequelize;
@ -41,6 +42,7 @@ class _DimensionStore {
NebNotificationUser,
NebIntegrationConfig,
Webhook,
BridgeRecord,
]);
}

View File

@ -0,0 +1,30 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_bridges", {
"id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false},
"type": {type: DataType.STRING, allowNull: false},
"name": {type: DataType.STRING, allowNull: false},
"avatarUrl": {type: DataType.STRING, allowNull: false},
"description": {type: DataType.STRING, allowNull: false},
"isEnabled": {type: DataType.BOOLEAN, allowNull: false},
"isPublic": {type: DataType.BOOLEAN, allowNull: false},
}))
.then(() => queryInterface.bulkInsert("dimension_bridges", [
{
type: "irc",
name: "IRC Bridge",
avatarUrl: "/img/avatars/irc.png",
isEnabled: true,
isPublic: true,
description: "Bridges IRC channels to rooms, supporting multiple networks",
},
]));
},
down: (queryInterface: QueryInterface) => {
return queryInterface.dropTable("dimension_bridges");
}
}

View File

@ -0,0 +1,32 @@
import { AutoIncrement, Column, Model, PrimaryKey, Table } from "sequelize-typescript";
import { IntegrationRecord } from "./IntegrationRecord";
@Table({
tableName: "dimension_bridges",
underscoredAll: false,
timestamps: false,
})
export default class BridgeRecord extends Model<BridgeRecord> implements IntegrationRecord {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
type: string;
@Column
name: string;
@Column
avatarUrl: string;
@Column
description: string;
@Column
isEnabled: boolean;
@Column
isPublic: boolean;
}

View File

@ -0,0 +1,32 @@
import { Integration } from "./Integration";
import BridgeRecord from "../db/models/BridgeRecord";
export class Bridge extends Integration {
constructor(bridge: BridgeRecord, public config: any) {
super(bridge);
this.category = "bridge";
this.requirements = [{
condition: "publicRoom",
expectedValue: true,
argument: null, // not used
}];
// We'll just say we aren't
this.isEncryptionSupported = false;
}
}
export interface IrcBridgeConfiguration {
availableNetworks: {
[networkId: string]: {
name: string;
bridgeUserId: string;
};
};
links: {
[networkId: string]: {
channelName: string;
addedByUserId: string;
}[];
};
}

View File

@ -2,7 +2,6 @@ Release checklist:
* IRC Bridge
* Update documentation
* Configuration migration (if possible)
* Lots of logging
* Final testing (widgets, bots, etc)
After release:

View File

@ -2,6 +2,7 @@
<li (click)="goto('')" [ngClass]="[isActive('', true) ? 'active' : '']">Dashboard</li>
<li (click)="goto('widgets')" [ngClass]="[isActive('widgets') ? 'active' : '']">Widgets</li>
<li (click)="goto('neb')" [ngClass]="[isActive('neb') ? 'active' : '']">go-neb</li>
<li (click)="goto('bridges')" [ngClass]="[isActive('bridges') ? 'active' : '']">Bridges</li>
</ul>
<span class="version">{{ version }}</span>

View File

@ -0,0 +1,37 @@
<div *ngIf="isLoading">
<my-spinner></my-spinner>
</div>
<div *ngIf="!isLoading">
<my-ibox title="Bridges">
<div class="my-ibox-content">
<p>
Bridges provide a way for rooms to interact with and/or bring in events from a third party network. For
example, an IRC bridge can allow IRC and matrix users to communicate with each other.
</p>
<table class="table table-striped table-condensed table-bordered">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th class="text-center" style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!bridges || bridges.length === 0">
<td colspan="2"><i>No bridges.</i></td>
</tr>
<tr *ngFor="let bridge of bridges trackById">
<td>{{ bridge.displayName }}</td>
<td>{{ bridge.description }}</td>
<td class="text-center">
<span class="editButton" [routerLink]="[bridge.type]" title="edit">
<i class="fa fa-pencil-alt"></i>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</my-ibox>
</div>

View File

@ -0,0 +1,8 @@
tr td:last-child {
vertical-align: middle;
}
.appsvcConfigButton,
.editButton {
cursor: pointer;
}

View File

@ -0,0 +1,28 @@
import { Component, OnInit } from "@angular/core";
import { ToasterService } from "angular2-toaster";
import { FE_Bridge } from "../../shared/models/integration";
import { AdminIntegrationsApiService } from "../../shared/services/admin/admin-integrations-api.service";
@Component({
templateUrl: "./bridges.component.html",
styleUrls: ["./bridges.component.scss"],
})
export class AdminBridgesComponent implements OnInit {
public isLoading = true;
public bridges: FE_Bridge<any>[];
constructor(private adminIntegrations: AdminIntegrationsApiService,
private toaster: ToasterService) {
}
public ngOnInit() {
this.adminIntegrations.getAllBridges().then(bridges => {
this.bridges = bridges.filter(b => b.isEnabled);
this.isLoading = false;
}).catch(err => {
console.error(err);
this.toaster.pop("error", "Failed to load bridges");
});
}
}

View File

@ -0,0 +1,49 @@
<div *ngIf="isLoading">
<my-spinner></my-spinner>
</div>
<div *ngIf="!isLoading">
<my-ibox title="IRC Bridge Configurations">
<div class="my-ibox-content">
<p>
<a href="https://github.com/matrix-org/matrix-appservice-irc" target="_blank">matrix-appservice-irc</a>
is an IRC bridge that supports multiple IRC networks. Dimension is capable of using multiple IRC
bridges to better distribute the load across multiple networks in large deployments.
</p>
<table class="table table-striped table-condensed table-bordered">
<thead>
<tr>
<th>Name</th>
<th>Enabled Networks</th>
<th class="text-center" style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!configurations || configurations.length === 0">
<td colspan="2"><i>No bridge configurations.</i></td>
</tr>
<tr *ngFor="let bridge of configurations trackById">
<td>
{{ bridge.upstreamId ? "matrix.org's bridge" : "Self-hosted bridge" }}
<span class="text-muted" style="display: inline-block;" *ngIf="!bridge.upstreamId">({{ bridge.adminUrl }})</span>
</td>
<td>
{{ getEnabledNetworksString(bridge) }}
</td>
<td class="text-center">
<button type="button" class="editButton" (click)="editNetworks(bridge)">
<i class="fa fa-pencil-alt"></i>
</button>
</td>
</tr>
</tbody>
</table>
<button type="button" class="btn btn-success btn-sm" (click)="addModularHostedBridge()" *ngIf="!hasModularBridge">
<i class="fa fa-plus"></i> Add matrix.org's bridge
</button>
<button type="button" class="btn btn-success btn-sm" (click)="addSelfHostedBridge()">
<i class="fa fa-plus"></i> Add self-hosted bridge
</button>
</div>
</my-ibox>
</div>

View File

@ -0,0 +1,31 @@
import { Component } from "@angular/core";
@Component({
templateUrl: "./irc.component.html",
styleUrls: ["./irc.component.scss"],
})
export class AdminIrcBridgeComponent {
public isLoading = true;
public hasModularBridge = false;
public configurations: any[] = [];
constructor() {
}
public getEnabledNetworksString(bridge: any): string {
return "TODO: " + bridge;
}
public addModularHostedBridge() {
}
public addSelfHostedBridge() {
}
public editNetworks(bridge: any) {
console.log(bridge);
}
}

View File

@ -22,9 +22,12 @@ export class AdminWidgetsComponent {
public widgets: FE_Widget[];
constructor(private adminIntegrationsApi: AdminIntegrationsApiService, private toaster: ToasterService, private modal: Modal) {
this.adminIntegrationsApi.getAllWidgets().then(integrations => {
this.adminIntegrationsApi.getAllWidgets().then(widgets => {
this.isLoading = false;
this.widgets = integrations.widgets;
this.widgets = widgets;
}).catch(err => {
console.error(err);
this.toaster.pop("error", "Failed to load widgets");
});
}

View File

@ -62,6 +62,9 @@ import { ConfigSimpleBotComponent } from "./configs/simple-bot/simple-bot.compon
import { ConfigScreenComplexBotComponent } from "./configs/complex-bot/config-screen/config-screen.complex-bot.component";
import { RssComplexBotConfigComponent } from "./configs/complex-bot/rss/rss.complex-bot.component";
import { TravisCiComplexBotConfigComponent } from "./configs/complex-bot/travisci/travisci.complex-bot.component";
import { ConfigScreenBridgeComponent } from "./configs/bridge/config-screen/config-screen.bridge.component";
import { AdminBridgesComponent } from "./admin/bridges/bridges.component";
import { AdminIrcBridgeComponent } from "./admin/bridges/irc/irc.component";
@NgModule({
imports: [
@ -118,6 +121,9 @@ import { TravisCiComplexBotConfigComponent } from "./configs/complex-bot/travisc
ConfigScreenComplexBotComponent,
RssComplexBotConfigComponent,
TravisCiComplexBotConfigComponent,
ConfigScreenBridgeComponent,
AdminBridgesComponent,
AdminIrcBridgeComponent,
// Vendor
],

View File

@ -21,6 +21,8 @@ import { AdminEditNebComponent } from "./admin/neb/edit/edit.component";
import { AdminAddSelfhostedNebComponent } from "./admin/neb/add-selfhosted/add-selfhosted.component";
import { RssComplexBotConfigComponent } from "./configs/complex-bot/rss/rss.complex-bot.component";
import { TravisCiComplexBotConfigComponent } from "./configs/complex-bot/travisci/travisci.complex-bot.component";
import { AdminBridgesComponent } from "./admin/bridges/bridges.component";
import { AdminIrcBridgeComponent } from "./admin/bridges/irc/irc.component";
const routes: Routes = [
{path: "", component: HomeComponent},
@ -68,6 +70,21 @@ const routes: Routes = [
},
]
},
{
path: "bridges",
data: {breadcrumb: "Bridges", name: "Bridges"},
children: [
{
path: "",
component: AdminBridgesComponent,
},
{
path: "irc",
component: AdminIrcBridgeComponent,
data: {breadcrumb: "IRC Bridge", name: "IRC Bridge"},
},
],
},
],
},
{
@ -125,6 +142,15 @@ const routes: Routes = [
},
],
},
// {
// path: "bridge",
// children: [
// {
// path: "irc",
//
// }
// ]
// }
],
},
{

View File

@ -0,0 +1,69 @@
import { OnDestroy, OnInit } from "@angular/core";
import { FE_Bridge } from "../../shared/models/integration";
import { ActivatedRoute } from "@angular/router";
import { Subscription } from "rxjs/Subscription";
import { IntegrationsApiService } from "../../shared/services/integrations/integrations-api.service";
import { ToasterService } from "angular2-toaster";
import { ServiceLocator } from "../../shared/registry/locator.service";
import { ScalarClientApiService } from "../../shared/services/scalar/scalar-client-api.service";
export class BridgeComponent<T> implements OnInit, OnDestroy {
public isLoading = true;
public isUpdating = false;
public bridge: FE_Bridge<T>;
public newConfig: T;
public roomId: string;
private routeQuerySubscription: Subscription;
protected toaster = ServiceLocator.injector.get(ToasterService);
protected integrationsApi = ServiceLocator.injector.get(IntegrationsApiService);
protected route = ServiceLocator.injector.get(ActivatedRoute);
protected scalarClientApi = ServiceLocator.injector.get(ScalarClientApiService);
constructor(private integrationType: string) {
this.isLoading = true;
this.isUpdating = false;
}
public ngOnInit(): void {
this.routeQuerySubscription = this.route.queryParams.subscribe(params => {
this.roomId = params['roomId'];
this.loadBridge();
});
}
public ngOnDestroy(): void {
if (this.routeQuerySubscription) this.routeQuerySubscription.unsubscribe();
}
private loadBridge() {
this.isLoading = true;
this.isUpdating = false;
this.newConfig = <T>{};
this.integrationsApi.getIntegrationInRoom("bridge", this.integrationType, this.roomId).then(i => {
this.bridge = <FE_Bridge<T>>i;
this.newConfig = JSON.parse(JSON.stringify(this.bridge.config));
this.isLoading = false;
}).catch(err => {
console.error(err);
this.toaster.pop("error", "Failed to load configuration");
});
}
public save(): void {
this.isUpdating = true;
this.integrationsApi.setIntegrationConfiguration("bridge", this.integrationType, this.roomId, this.newConfig).then(() => {
this.toaster.pop("success", "Configuration updated");
this.bridge.config = this.newConfig;
this.isUpdating = false;
}).catch(err => {
console.error(err);
this.toaster.pop("error", "Error updating configuration");
this.isUpdating = false;
});
}
}

View File

@ -0,0 +1,6 @@
<div *ngIf="bridgeComponent.isLoading">
<my-spinner></my-spinner>
</div>
<div *ngIf="!bridgeComponent.isLoading">
<ng-container *ngTemplateOutlet="bridgeParamsTemplate"></ng-container>
</div>

View File

@ -0,0 +1,16 @@
import { Component, ContentChild, Input, TemplateRef } from "@angular/core";
import { BridgeComponent } from "../bridge.component";
@Component({
selector: "my-bridge-config",
templateUrl: "config-screen.bridge.component.html",
styleUrls: ["config-screen.bridge.component.scss"],
})
export class ConfigScreenBridgeComponent {
@Input() bridgeComponent: BridgeComponent<any>;
@ContentChild(TemplateRef) bridgeParamsTemplate: TemplateRef<any>;
constructor() {
}
}

View File

@ -259,6 +259,8 @@ export class RiotHomeComponent {
console.error(err);
if (requirement.expectedValue) return Promise.reject("Expected to be able to send specific event types");
});
case "userInRoom":
// TODO: Implement
default:
return Promise.reject("Requirement '" + requirement.condition + "' not found");
}

View File

@ -1,5 +1,6 @@
import { FE_Widget } from "./integration";
import { FE_Bridge, FE_Widget } from "./integration";
export interface FE_IntegrationsResponse {
widgets: FE_Widget[];
bridges: FE_Bridge<any>[];
}

View File

@ -26,6 +26,11 @@ export interface FE_ComplexBot<T> extends FE_Integration {
config: T;
}
export interface FE_Bridge<T> extends FE_Integration {
bridgeUserId: string;
config: T;
}
export interface FE_Widget extends FE_Integration {
options: any;
}
@ -44,7 +49,7 @@ export interface FE_JitsiWidget extends FE_Widget {
}
export interface FE_IntegrationRequirement {
condition: "publicRoom" | "canSendEventTypes";
condition: "publicRoom" | "canSendEventTypes" | "userInRoom";
argument: any;
expectedValue: any;
}

View File

@ -1,7 +1,7 @@
import { Injectable } from "@angular/core";
import { Http } from "@angular/http";
import { AuthedApi } from "../authed-api";
import { FE_IntegrationsResponse } from "../../models/dimension-responses";
import { FE_Bridge, FE_Widget } from "../../models/integration";
@Injectable()
export class AdminIntegrationsApiService extends AuthedApi {
@ -9,10 +9,14 @@ export class AdminIntegrationsApiService extends AuthedApi {
super(http);
}
public getAllWidgets(): Promise<FE_IntegrationsResponse> {
public getAllWidgets(): Promise<FE_Widget[]> {
return this.authedGet("/api/v1/dimension/admin/integrations/widget/all").map(r => r.json()).toPromise();
}
public getAllBridges(): Promise<FE_Bridge<any>[]> {
return this.authedGet("/api/v1/dimension/admin/integrations/bridge/all").map(r => r.json()).toPromise();
}
public toggleIntegration(category: string, type: string, enabled: boolean): Promise<any> {
return this.authedPost("/api/v1/dimension/admin/integrations/" + category + "/" + type + "/enabled", {enabled: enabled}).map(r => r.json()).toPromise();
}