Compare commits

...

13 Commits

Author SHA1 Message Date
FoxxMD
296f1c8dee Merge branch 'edge' 2022-09-14 15:30:27 -04:00
FoxxMD
77856a6d97 chore: Bump version 2022-09-14 15:30:12 -04:00
FoxxMD
e32ac60db5 Merge branch 'edge' 2022-09-14 15:29:13 -04:00
FoxxMD
052c1218c6 feat(ui): Add home logo 2022-09-14 15:26:59 -04:00
FoxxMD
fcf718f1d0 fix(ui): Fix typo in reddit status indicator switch 2022-09-14 13:21:48 -04:00
FoxxMD
95216b3950 docs: Add GHCR image location to install docs
Closes #109
2022-09-13 15:06:13 -04:00
FoxxMD
58a21e8d05 Scope required permissions for github token 2022-09-13 14:35:11 -04:00
FoxxMD
49ac8cda19 feat: Update github actions to push to multiple registries
And add documentation for GH actions local dev/testing
2022-09-13 14:23:22 -04:00
FoxxMD
e736379f85 feat(ui): Improve guest expiration interface
* Set default time to 24 hours
* Add label and tooltip for expiration datetime picker
2022-09-07 11:57:29 -04:00
FoxxMD
c0e1a93fb4 fix(config): Use correct filter defaults data structure for runs
Should use "json" data structure type so named filters can be used in defaults on runs
2022-09-06 11:18:24 -04:00
FoxxMD
bd35b06ebf fix(filter): Fix missed toLowerCase for named filter getter 2022-09-02 17:41:28 -04:00
FoxxMD
f852e85234 feat(author): authorIs 'name' criteria may be regular expression 2022-09-02 16:21:53 -04:00
FoxxMD
661ae11e18 refactor(ui): Replace websockets opStats with client delta polling #106
* Refactor/remove websockets functionality for relaying opstats from server with direct polling by client
* Implement delta responses initially introduced in #91 to reduce bandwidth
2022-09-01 16:01:37 -04:00
29 changed files with 312 additions and 332 deletions

View File

@@ -13,7 +13,7 @@ coverage
*.json5
*.yaml
*.yml
*.env
# exceptions
!heroku.yml

3
.github/push-hook-sample.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"ref": "refs/heads/edge"
}

View File

@@ -1,4 +1,14 @@
name: Publish Docker image to Dockerhub
name: Publish Docker image to registries
# Builds image and tags based on the type of push event:
# * branch push -> tag is branch name IE context-mod:edge
# * release (tag) -> tag is release name IE context-mod:0.13.0
#
# Then pushes tagged images to multiple registries
#
# Based on
# https://github.com/docker/build-push-action/blob/master/docs/advanced/push-multi-registries.md
# https://github.com/docker/metadata-action
on:
push:
@@ -13,8 +23,12 @@ on:
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
name: Build and Push Docker image to registries
runs-on: ubuntu-latest
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v2
@@ -25,12 +39,22 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
with:
images: foxxmd/context-mod
images: |
foxxmd/context-mod
ghcr.io/foxxmd/context-mod
# generate Docker tags based on the following events/attributes
tags: |
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
@@ -40,7 +64,8 @@ jobs:
latest=false
- name: Build and push Docker image
uses: docker/build-push-action@v2
if: ${{ !env.ACT }}
uses: docker/build-push-action@v3
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

2
.gitignore vendored
View File

@@ -336,6 +336,7 @@ web_modules/
# dotenv environment variables file
.env
.env.test
*.env
# parcel-bundler cache (https://parceljs.org/)
.cache
@@ -391,6 +392,7 @@ dist
*.json5
!src/Schema/*.json
!.github/push-hook-sample.json
!docs/**/*.json5
!docs/**/*.yaml
!docs/**/*.json

3
act.env.example Normal file
View File

@@ -0,0 +1,3 @@
GITHUB_TOKEN=
DOCKERHUB_USERNAME=
DOCKER_PASSWORD=

View File

@@ -1,5 +1,17 @@
TODO add more development sections...
# Developing/Testing Github Actions
Use [act](https://github.com/nektos/act) to run Github actions locally.
An example secrets file can be found in the project working directory at [act.env.example](act.env.example)
Modify [push-hook-sample.json](.github/push-hook-sample.json) to point to the local branch you want to run a `push` event trigger on, then run this command from the project working directory:
```bash
act -e .github/push-hook-sample.json --secret-file act.env
```
# Mocking Reddit API
Using [MockServer](https://www.mock-server.com/)

View File

@@ -8,7 +8,10 @@ ContextMod can be run on almost any operating system but it is recommended to us
PROTIP: Using a container management tool like [Portainer.io CE](https://www.portainer.io/products/community-edition) will help with setup/configuration tremendously.
### [Dockerhub](https://hub.docker.com/r/foxxmd/context-mod)
Images available from these registeries:
* [Dockerhub](https://hub.docker.com/r/foxxmd/context-mod) - `docker.io/foxxmd/context-mod`
* [GHCR](https://github.com/foxxmd/context-mod/pkgs/container/context-mod) - `ghcr.io/foxxmd/context-mod`
An example of starting the container using the [minimum configuration](/docs/operator/configuration.md#minimum-config):
@@ -17,7 +20,7 @@ An example of starting the container using the [minimum configuration](/docs/ope
* Expose the web interface using the container port `8085`
```
docker run -d -v /host/path/folder:/config -p 8085:8085 foxxmd/context-mod
docker run -d -v /host/path/folder:/config -p 8085:8085 ghcr.io/foxxmd/context-mod:latest
```
The location of `DATA_DIR` in the container can be changed by passing it as an environmental variable EX `-e "DATA_DIR=/home/abc/config`
@@ -34,7 +37,7 @@ To get the UID and GID for the current user run these commands from a terminal:
* `id -g` -- prints GID
```
docker run -d -v /host/path/folder:/config -p 8085:8085 -e PUID=1000 -e PGID=1000 foxxmd/context-mod
docker run -d -v /host/path/folder:/config -p 8085:8085 -e PUID=1000 -e PGID=1000 ghcr.io/foxxmd/context-mod:latest
```
## Locally

View File

@@ -245,10 +245,11 @@ export const authorCriteriaProperties = ['name', 'flairCssClass', 'flairText', '
* */
export interface AuthorCriteria {
/**
* A list of reddit usernames (case-insensitive) to match against. Do not include the "u/" prefix
* A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the "u/" prefix
*
*
* EX to match against /u/FoxxMD and /u/AnotherUser use ["FoxxMD","AnotherUser"]
* @examples ["FoxxMD","AnotherUser"]
* @examples ["FoxxMD","AnotherUser", "/.*Foxx.\/*i"]
* */
name?: string[],
/**

View File

@@ -42,4 +42,4 @@ export const filterCriteriaDefault: FilterCriteriaDefaults = {
export const defaultDataDir = path.resolve(__dirname, '../..');
export const defaultConfigFilenames = ['config.json', 'config.yaml'];
export const VERSION = '0.12.1';
export const VERSION = '0.12.2';

View File

@@ -418,6 +418,7 @@ export class ConfigBuilder {
}
structuredRuns.push({
...r,
filterCriteriaDefaults: configFilterDefaultsFromRun,
checks: structuredChecks,
authorIs: derivedRunAuthorIs,
itemIs: derivedRunItemIs
@@ -642,7 +643,7 @@ const getNamedOrReturn = <T>(namedFilters: Map<string, NamedCriteria<T>>, filter
if(!namedFilters.has(x.toLocaleLowerCase())) {
throw new Error(`No named ${filterName} criteria with the name "${x}"`);
}
return namedFilters.get(x) as NamedCriteria<T>;
return namedFilters.get(x.toLocaleLowerCase()) as NamedCriteria<T>;
}
if(asNamedCriteria(x)) {
return x;

View File

@@ -24,7 +24,7 @@ import {RunResultEntity} from "../Common/Entities/RunResultEntity";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {RunnableBase} from "../Common/RunnableBase";
import {RunnableBaseJson, RunnableBaseOptions, StructuredRunnableBase} from "../Common/Infrastructure/Runnable";
import {FilterCriteriaDefaults} from "../Common/Infrastructure/Filters/FilterShapes";
import {FilterCriteriaDefaults, FilterCriteriaDefaultsJson} from "../Common/Infrastructure/Filters/FilterShapes";
import {IncludesData} from "../Common/Infrastructure/Includes";
export class Run extends RunnableBase {
@@ -284,7 +284,7 @@ export interface IRun extends PostBehavior, RunnableBaseJson {
*
* Default behavior is to exclude all mods and automoderator from checks
* */
filterCriteriaDefaults?: FilterCriteriaDefaults
filterCriteriaDefaults?: FilterCriteriaDefaultsJson
/**
* Use this option to override the `dryRun` setting for all Actions of all Checks in this Run
@@ -326,4 +326,5 @@ export interface RunConfigHydratedData extends IRun {
export interface RunConfigObject extends Omit<RunConfigHydratedData, 'authorIs' | 'itemIs'>, StructuredRunnableBase {
checks: ActivityCheckObject[]
filterCriteriaDefaults?: FilterCriteriaDefaults
}

View File

@@ -289,10 +289,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"

View File

@@ -665,10 +665,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"
@@ -2180,69 +2181,6 @@
},
"type": "object"
},
"FilterCriteriaDefaults": {
"properties": {
"authorIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<AuthorCriteria>"
},
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
}
]
},
"type": "array"
}
]
},
"authorIsBehavior": {
"enum": [
"merge",
"replace"
],
"type": "string"
},
"itemIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<TypedActivityState>"
},
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
}
]
},
"type": "array"
}
]
},
"itemIsBehavior": {
"description": "Determine how itemIs defaults behave when itemIs is present on the check\n\n* merge => adds defaults to check's itemIs\n* replace => check itemIs will replace defaults (no defaults used)",
"enum": [
"merge",
"replace"
],
"type": "string"
}
},
"type": "object"
},
"FilterCriteriaDefaultsJson": {
"properties": {
"authorIs": {
@@ -2313,62 +2251,6 @@
},
"type": "object"
},
"FilterOptions<AuthorCriteria>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptions<TypedActivityState>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptionsConfig<ActivityState>": {
"properties": {
"exclude": {
@@ -5362,7 +5244,7 @@
"type": "boolean"
},
"filterCriteriaDefaults": {
"$ref": "#/definitions/FilterCriteriaDefaults",
"$ref": "#/definitions/FilterCriteriaDefaultsJson",
"description": "Set the default filter criteria for all checks. If this property is specified it will override any defaults passed from the bot's config\n\nDefault behavior is to exclude all mods and automoderator from checks"
},
"itemIs": {

View File

@@ -679,10 +679,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"

View File

@@ -133,10 +133,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"

View File

@@ -594,10 +594,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"

View File

@@ -562,10 +562,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"

View File

@@ -676,10 +676,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"
@@ -1961,13 +1962,10 @@
},
"type": "object"
},
"FilterCriteriaDefaults": {
"FilterCriteriaDefaultsJson": {
"properties": {
"authorIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<AuthorCriteria>"
},
{
"items": {
"anyOf": [
@@ -1976,12 +1974,19 @@
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
]
],
"description": "Determine how authorIs defaults behave when authorIs is present on the check\n\n* merge => merges defaults with check's authorIs\n* replace => check authorIs will replace defaults (no defaults used)"
},
"authorIsBehavior": {
"enum": [
@@ -1992,9 +1997,6 @@
},
"itemIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<TypedActivityState>"
},
{
"items": {
"anyOf": [
@@ -2006,10 +2008,16 @@
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
]
},
@@ -2024,62 +2032,6 @@
},
"type": "object"
},
"FilterOptions<AuthorCriteria>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptions<TypedActivityState>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptionsConfig<ActivityState>": {
"properties": {
"exclude": {
@@ -4933,7 +4885,7 @@
"type": "boolean"
},
"filterCriteriaDefaults": {
"$ref": "#/definitions/FilterCriteriaDefaults",
"$ref": "#/definitions/FilterCriteriaDefaultsJson",
"description": "Set the default filter criteria for all checks. If this property is specified it will override any defaults passed from the bot's config\n\nDefault behavior is to exclude all mods and automoderator from checks"
},
"itemIs": {

View File

@@ -2780,7 +2780,7 @@ export class SubredditResources {
const authPass = () => {
for (const n of nameVal) {
if (n.toLowerCase() === authorName.toLowerCase()) {
if (testMaybeStringRegex(n, authorName)[0]) {
return true;
}
}

View File

@@ -1029,6 +1029,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
...req.instancesViewData,
bots: resp.bots,
now: dayjs().add(1, 'minute').format('YYYY-MM-DDTHH:mm'),
defaultExpire: dayjs().add(1, 'day').format('YYYY-MM-DDTHH:mm'),
botId: (req.instance as CMInstance).getName(),
isOperator: isOp,
system: isOp ? {
@@ -1462,27 +1463,6 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
}
emitter.on('log', botWebLogListener);
socketListeners.set(socket.id, [...(socketListeners.get(socket.id) || []), botWebLogListener]);
// only setup streams if the user can actually access them (not just a web operator)
if(session.authBotId !== undefined) {
// streaming stats from client
const newStreams: (AbortController | NodeJS.Timeout)[] = [];
const interval = setInterval(async () => {
try {
const resp = await got.get(`${bot.normalUrl}/stats`, {
headers: {
'Authorization': `Bearer ${createToken(bot, user)}`,
}
}).json() as object;
io.to(session.id).emit('opStats', resp);
} catch (err: any) {
bot.logger.error(new ErrorWithCause('Could not retrieve stats', {cause: err}));
clearInterval(interval);
}
}, 5000);
newStreams.push(interval);
sockStreams.set(socket.id, newStreams);
}
}
}
}

View File

@@ -86,6 +86,24 @@ const generateDeltaResponse = (data: Record<string, any>, hash: string, response
// delta[k] = {new: newGuestItems, removed: removedGuestItems};
delta[k] = v;
break;
case 'subreddits':
// only used by opStats!
const refSubs = reference[k].map((x: any) => `${x.name}-${x.indicator}`);
const lastestSubs = v.map((x: any) => `${x.name}-${x.indicator}`);
if(symmetricalDifference(refSubs, lastestSubs).length === 0) {
continue;
}
const changedSubs = v.reduce((acc: any[], curr: any) => {
if(!reference[k].some((x: any) => x.name === curr.name && x.indicator === curr.indicator)) {
acc.push(curr);
}
return acc;
}, []);
delta[k] = changedSubs;
break
default:
if(!deepEqual(v, reference[k])) {
if(v !== null && typeof v === 'object' && reference[k] !== null && typeof reference[k] === 'object') {
@@ -104,6 +122,67 @@ const generateDeltaResponse = (data: Record<string, any>, hash: string, response
return resp;
}
export const opStatResponse = () => {
const middleware = [
authUserCheck(),
botRoute(false)
];
const response = async(req: Request, res: Response) =>
{
const responseType = req.query.type === 'delta' ? 'delta' : 'full';
let bots: Bot[] = [];
if(req.serverBot !== undefined) {
bots = [req.serverBot];
} else if(req.user !== undefined) {
bots = req.user.accessibleBots(req.botApp.bots);
}
const resp = [];
let index = 1;
for(const b of bots) {
resp.push({name: b.botName ?? `Bot ${index}`, data: {
status: b.running ? 'RUNNING' : 'NOT RUNNING',
indicator: b.running ? 'green' : 'red',
running: b.running,
startedAt: b.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
error: b.error,
subreddits: req.user?.accessibleSubreddits(b).map((manager: Manager) => {
let indicator;
if (manager.managerState.state === RUNNING && manager.queueState.state === RUNNING && manager.eventsState.state === RUNNING) {
indicator = 'green';
} else if (manager.managerState.state === STOPPED && manager.queueState.state === STOPPED && manager.eventsState.state === STOPPED) {
indicator = 'red';
} else {
indicator = 'yellow';
}
return {
name: manager.displayLabel,
indicator,
};
}),
}});
index++;
}
const deltaResp = [];
for(const bResp of resp) {
const hash = `${req.user?.name}-opstats-${bResp.name}`;
const respData = generateDeltaResponse(bResp.data, hash, responseType);
if(Object.keys(respData).length !== 0) {
deltaResp.push({data: respData, name: bResp.name});
}
}
if(deltaResp.length === 0) {
return res.status(304).send();
}
return res.json(deltaResp);
}
return [...middleware, response];
}
const liveStats = () => {
const middleware = [
authUserCheck(),

View File

@@ -13,7 +13,7 @@ import http from "http";
import {heartbeat} from "./routes/authenticated/applicationRoutes";
import logs from "./routes/authenticated/user/logs";
import status from './routes/authenticated/user/status';
import liveStats from './routes/authenticated/user/liveStats';
import liveStats, {opStatResponse} from './routes/authenticated/user/liveStats';
import {
actionedEventsRoute,
actionRoute, addGuestModRoute,
@@ -161,41 +161,8 @@ const rcbServer = async function (options: OperatorConfigWithFileContext) {
server.getAsync('/logs', ...logs());
server.getAsync('/stats', [authUserCheck(), botRoute(false)], async (req: Request, res: Response) => {
let bots: Bot[] = [];
if(req.serverBot !== undefined) {
bots = [req.serverBot];
} else if(req.user !== undefined) {
bots = req.user.accessibleBots(req.botApp.bots);
}
const resp = [];
let index = 1;
for(const b of bots) {
resp.push({name: b.botName ?? `Bot ${index}`, data: {
status: b.running ? 'RUNNING' : 'NOT RUNNING',
indicator: b.running ? 'green' : 'red',
running: b.running,
startedAt: b.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
error: b.error,
subreddits: req.user?.accessibleSubreddits(b).map((manager: Manager) => {
let indicator;
if (manager.managerState.state === RUNNING && manager.queueState.state === RUNNING && manager.eventsState.state === RUNNING) {
indicator = 'green';
} else if (manager.managerState.state === STOPPED && manager.queueState.state === STOPPED && manager.eventsState.state === STOPPED) {
indicator = 'red';
} else {
indicator = 'yellow';
}
return {
name: manager.displayLabel,
indicator,
};
}),
}});
index++;
}
return res.json(resp);
});
server.getAsync('/stats', ...opStatResponse());
const passLogs = async (req: Request, res: Response, next: Function) => {
// @ts-ignore
req.sysLogs = sysLogs;

View File

@@ -190,3 +190,8 @@ li > ul {
.introjs-tooltip-title,.introjs-tooltiptext {
color: black;
}
.guestAdd {
border-top: 1px solid white;
padding-top: 0.5em;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -21,6 +21,7 @@
<div class="container mx-auto">
<div class="flex items-center justify-between">
<div class="flex items-center flex-grow pr-4">
<a href="/"><img src="/public/logo.png" style="max-height:40px; padding-right: 0.75rem;"/></a>
<% if(locals.title !== undefined) { %>
<a href="/events?instance=<%= instance %>&bot=<%= bot %><%= subreddit !== undefined ? `&subreddit=${subreddit}` : '' %>"><%= title %></a>
<% } %>

View File

@@ -20,7 +20,7 @@
statusEl.innerHTML = '<span class="iconify-inline green" data-icon="ep:circle-check-filled"></span>';
break;
default:
dstatusEl.innerHTML = '<span class="iconify-inline red" data-icon="ep:warning-filled"></span>';
statusEl.innerHTML = '<span class="iconify-inline red" data-icon="ep:warning-filled"></span>';
break;
}
// data.page.updated_at

View File

@@ -2,6 +2,7 @@
<div class="container mx-auto">
<div class="flex items-center justify-between">
<div class="flex items-center flex-grow pr-4">
<a href="/"><img src="/public/logo.png" style="max-height:40px;"/></a>
<% if(locals.instances !== undefined) { %>
<ul class="inline-flex flex-wrap">
<% instances.forEach(function (data) { %>

View File

@@ -2,6 +2,7 @@
<div class="container mx-auto">
<div class="flex items-center justify-between">
<div class="flex items-center flex-grow pr-4">
<a href="/"><img src="/public/logo.png" style="max-height:40px; padding-right: 0.75rem;"/></a>
<% if(locals.title !== undefined) { %>
<%= title %>
<% } %>

View File

@@ -288,10 +288,25 @@
style="width:200px;"
class="guestAddName border-gray-50 placeholder-gray-500 rounded mr-1 p-1 text-black"
placeholder="userName"/>
<div class="mt-2">
<span class="has-tooltip">
<span style="margin-top:55px" class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black space-y-3 p-2 text-left'>
When should Guest Access expire for this user?
</span>
<span>
Expires At<svg xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline-block cursor-help"
fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<use xlink:href="public/questionsymbol.svg#q" />
</svg>
</span>
</span>
<input type="datetime-local"
class="guestAddTime border-gray-50 placeholder-gray-500 mt-2 mr-2 rounded text-black"
value="<%= now %>"
class="guestAddTime border-gray-50 placeholder-gray-500 mr-2 rounded text-black"
value="<%= defaultExpire %>"
min="<%= now %>"/>
</div>
</div>
<a href="" class="addGuest">Add</a>
@@ -1095,6 +1110,44 @@
const delayedItemsMap = new Map();
let lastSeenIdentifier = null;
const subIndicators = ['red', 'green', 'yellow'];
function updateOpStats(resp, responseType) {
for (const b of resp) {
const {
name,
data: {
running,
indicator,
subreddits = [],
} = {},
} = b;
const botTab = document.querySelector(`[data-bot="${name}"] .botTabStatus`);
if (botTab !== null) {
if (running !== undefined) {
const currentStatusClass = `bg-${running ? 'green' : 'red'}-400`;
const oppositeStatusClass = `bg-${running ? 'red' : 'green'}-400`;
if (!botTab.classList.contains(currentStatusClass)) {
botTab.classList.remove(oppositeStatusClass);
botTab.classList.add(currentStatusClass);
}
}
}
for (const subData of subreddits) {
const subredditTab = document.querySelector(`[data-bot="${name}"] [data-subreddit="${subData.name}"] .subredditTabStatus`);
if (subredditTab !== null) {
const currentSubIndicatorClass = `bg-${subData.indicator}-400`;
const nonSubIndicatorClasses = subIndicators.filter(x => x !== subData.indicator).map(x => `bg-${x}-400`);
if (!subredditTab.classList.contains(currentSubIndicatorClass)) {
for (const nonIndicator of nonSubIndicatorClasses) {
subredditTab.classList.remove(nonIndicator);
}
subredditTab.classList.add(currentSubIndicatorClass);
}
}
}
}
}
function updateLiveStats(resp, sub, bot, responseType) {
let el;
@@ -1360,30 +1413,32 @@
const now = dayjs();
el.innerHTML = '';
if(data.length === 0) {
const node = document.createElement("LI");
node.classList.add('smallLi');
node.appendChild(document.createTextNode('None'));
el.appendChild(node);
} else {
for(const g of data) {
if(el !== null) {
el.innerHTML = '';
if(data.length === 0) {
const node = document.createElement("LI");
node.classList.add('smallLi');
let relTime = g.expiresAt === undefined ? 'Never' : dayjs.duration(dayjs(g.expiresAt).diff(now)).humanize();
let guestText = g.name;
if(isAll) {
guestText += ` (${g.subreddits.length} Subs, at least ${relTime})`;
} else {
guestText += ` (${relTime})`;
}
node.appendChild(document.createTextNode(guestText));
node.insertAdjacentHTML('beforeend', `<a href="" class="remove ml-1" data-name="${g.name}"><span class="cancellable iconify-inline" data-icon="icons8:cancel"></span></a>`);
node.addEventListener('click', e => {
e.preventDefault();
removeGuestMod(bot, sub, g.name);
});
node.appendChild(document.createTextNode('None'));
el.appendChild(node);
} else {
for(const g of data) {
const node = document.createElement("LI");
node.classList.add('smallLi');
let relTime = g.expiresAt === undefined ? 'Never' : dayjs.duration(dayjs(g.expiresAt).diff(now)).humanize();
let guestText = g.name;
if(isAll) {
guestText += ` (${g.subreddits.length} Subs, at least ${relTime})`;
} else {
guestText += ` (${relTime})`;
}
node.appendChild(document.createTextNode(guestText));
node.insertAdjacentHTML('beforeend', `<a href="" class="remove ml-1" data-name="${g.name}"><span class="cancellable iconify-inline" data-icon="icons8:cancel"></span></a>`);
node.addEventListener('click', e => {
e.preventDefault();
removeGuestMod(bot, sub, g.name);
});
el.appendChild(node);
}
}
}
}
@@ -1410,6 +1465,23 @@
});
});
function getOpStats(responseType = 'full') {
console.debug(`Getting op live stats for <%= instanceId %>`)
return fetch(`/api/stats?instance=<%= instanceId %>&type=${responseType}`)
.then(response => {
if(response.status === 304) {
return Promise.resolve(false);
}
return response.json();
})
.then(resp => {
if(resp === false) {
return;
}
updateOpStats(resp, responseType);
});
}
function getLiveStats(bot, sub, responseType = 'full') {
console.debug(`Getting live stats for ${bot} ${sub}`)
return fetch(`/api/liveStats?instance=<%= instanceId %>&bot=${bot}&subreddit=${sub}&type=${responseType}`)
@@ -1515,6 +1587,19 @@
onVisible(el, () => onSubVisible(bot, sub));
});
//window.init = true;
let opTimeoutId = null;
let opTimeout = () => {
getOpStats('full').then(() => {
opTimeoutId = setInterval(() => {
getOpStats('delta').catch((err) => {
console.error(err);
clearInterval(opTimeoutId);
})
}, 10000);
});
}
let backgroundTimeout = null;
document.addEventListener("visibilitychange", (e) => {
@@ -1531,6 +1616,9 @@
controller.abort();
}
backgroundTimeout = null;
clearInterval(opTimeoutId);
opTimeoutId = null;
window.init = true;
}, 15000);
} else {
// cancel real-time data timeout because page is visible again
@@ -1547,10 +1635,15 @@
recentlySeen.delete(lastSeenIdentifier);
onSubVisible(bot, sub);
}
if(opTimeoutId === null) {
opTimeout();
}
}
}
});
opTimeout();
var searchParams = new URLSearchParams(window.location.search);
const shownSub = searchParams.get('sub') || 'All'
let shownBot = searchParams.get('bot');
@@ -1619,43 +1712,6 @@
socket.on("connect", () => {
document.body.classList.add('connected')
const shownSub = searchParams.get('sub') || 'All'
let shownBot = searchParams.get('bot');
window.socket.emit('viewing', {bot: shownBot, subreddit: shownSub});
// TODO web logging
// socket.on('log')
const subIndicators = ['red', 'green', 'yellow'];
socket.on('opStats', (resp) => {
for(const b of resp) {
const {name, data} = b;
const botTab = document.querySelector(`[data-bot="${name}"] .botTabStatus`);
if(botTab !== null) {
const currentStatusClass = `bg-${data.running ? 'green' : 'red'}-400`;
const oppositeStatusClass = `bg-${data.running ? 'red' : 'green'}-400`;
if(!botTab.classList.contains(currentStatusClass)) {
botTab.classList.remove(oppositeStatusClass);
botTab.classList.add(currentStatusClass);
}
}
for (const subData of data.subreddits) {
const subredditTab = document.querySelector(`[data-bot="${name}"] [data-subreddit="${subData.name}"] .subredditTabStatus`);
if(subredditTab !== null) {
const currentSubIndicatorClass = `bg-${subData.indicator}-400`;
const nonSubIndicatorClasses = subIndicators.filter(x => x !== subData.indicator).map(x => `bg-${x}-400`);
if(!subredditTab.classList.contains(currentSubIndicatorClass)) {
for(const nonIndicator of nonSubIndicatorClasses) {
subredditTab.classList.remove(nonIndicator);
}
subredditTab.classList.add(currentSubIndicatorClass);
}
}
}
}
});
});
socket.on('disconnect', () => {