Merge branch 'SwitchToLocalDev' into 'master'

Link K8S deps properly

See merge request Dunemask/qualiteer!3
This commit is contained in:
Elijah Dunemask 2022-10-08 17:47:46 +00:00
commit 945afdfbbe
64 changed files with 4282 additions and 3069 deletions

View file

@ -1,3 +0,0 @@
{
"files": {}
}

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ node_modules/
build/
jobs/*
qltr-executor
.env

67
.replit
View file

@ -1,67 +0,0 @@
# The command that is executed when the run button is clicked.
run = ["echo", "run"]
entrypoint = "index.js"
[nix]
channel = "stable-21_11"
[env]
XDG_CONFIG_HOME = "/home/runner/.config"
[packager]
language = "nodejs"
[packager.features]
packageSearch = true
guessImports = true
enabledForHosting = false
[unitTest]
language = "nodejs"
[languages.javascript]
pattern = "**/{*.js,*.jsx,*.ts,*.tsx}"
[languages.javascript.languageServer]
start = [ "typescript-language-server", "--stdio" ]
[debugger]
support = true
[debugger.interactive]
transport = "localhost:0"
startCommand = [ "dap-node" ]
[debugger.interactive.initializeMessage]
command = "initialize"
type = "request"
[debugger.interactive.initializeMessage.arguments]
clientID = "replit"
clientName = "replit.com"
columnsStartAt1 = true
linesStartAt1 = true
locale = "en-us"
pathFormat = "path"
supportsInvalidatedEvent = true
supportsProgressReporting = true
supportsRunInTerminalRequest = true
supportsVariablePaging = true
supportsVariableType = true
[debugger.interactive.launchMessage]
command = "launch"
type = "request"
[debugger.interactive.launchMessage.arguments]
args = []
console = "externalTerminal"
cwd = "."
environment = []
pauseForSourceMap = false
program = "./index.js"
request = "launch"
sourceMaps = true
stopOnEntry = false
type = "pwa-node"

View file

@ -1,4 +1,4 @@
FROM node:16
FROM node:18
WORKDIR /dunemask/net/qualiteer
# Copy dependencies
COPY package.json .
@ -6,11 +6,13 @@ COPY package-lock.json .
RUN npm i
# Copy react build resources over
COPY public public
COPY dist dist
COPY src src
COPY lib lib
COPY index.html .
RUN npm run build:react
COPY executor.config.js .
COPY vite.config.js .
RUN npm run build:all
# Copy bin over
COPY bin bin
CMD ["npm","start"]

View file

@ -7,20 +7,20 @@
- [x] Initial Skeleton
- [x] Frontend Drafts
- [x] Frontend Core
- [ ] Frontend Pages
- [x] Frontend Pages
## v0.0.2
- [ ] Database Queries (Req PG)
- [ ] Database Tables (Req PG)
- [ ] Backend Routes (Req Database)
- [x] Database Queries (Req PG)
- [x] Database Tables (Req PG)
- [x] Backend Routes (Req Database)
- [ ] Crons
## v0.0.3
- [ ] Rabbitmq Consumers (Req Database)
- [ ] Alerting
- [ ] Silencing
- [x] Rabbitmq Consumers (Req Database)
- [x] Alerting
- [x] Silencing
## v0.0.4

Binary file not shown.

2
dist/app.js vendored
View file

@ -4,5 +4,3 @@ import Qualiteer from "qualiteer";
const qltr = new Qualiteer();
await qltr.start();
process.env.INTERNAL_EXECUTOR = "true";

File diff suppressed because one or more lines are too long

View file

@ -3,5 +3,5 @@ export default function executorConfig(payload){
command: ({ command }) => command,
url: ({ url }) => url,
jobId: ({ jobId }) => jobId,
}
};
}

View file

@ -6,13 +6,14 @@ import { INFO, OK, logInfo } from "../util/logging.js";
// Import Core Modules
import buildRoutes from "../routes/router.js";
import buildPostgres from "../database/postgres.js";
import pg from "../database/postgres.js";
import injectSockets from "../sockets/socket-server.js";
import JobManager from "../jobs/JobManager.js";
import buildRabbiteer from "../rabbit/rabbit-workers.js";
// Constants
const title = "QLTR";
const rabbiteerEnabled = process.env.QUALITEER_RABBITEER_ENABLED !== "false";
const port = process.env.QUALITEER_DEV_PORT ?? 52000;
// Class
@ -27,7 +28,7 @@ export default class Qualiteer {
logInfo(fig.textSync(title, "Cyberlarge"));
INFO("INIT", "Initializing...");
this.app = express();
this.pg = buildPostgres();
this.pg = pg;
this.server = http.createServer(this.app);
this.sockets = injectSockets(this.server, this.jobs);
this.routes = buildRoutes(this.pg, this.sockets);
@ -37,7 +38,8 @@ export default class Qualiteer {
async _connect() {
await this.pg.connect();
// await this.rabbiteer.connect();
if (!rabbiteerEnabled) return;
await this.rabbiteer.connect();
}
start() {

23
lib/database/delays.js Normal file
View file

@ -0,0 +1,23 @@
const seconds = 1000;
const minutes = 60 * seconds;
const hours = 60 * minutes;
export const DELAYS = {
"1sec": 1 * seconds,
"5sec": 5 * seconds,
"10sec": 10 * seconds,
"30sec": 30 * seconds,
"1min": 1 * minutes,
"5min": 5 * minutes,
"10min": 10 * minutes,
"15min": 15 * minutes,
"30min": 30 * minutes,
"1hour": 1 * hours,
"2hour": 2 * hours,
"3hour": 3 * hours,
"4hour": 4 * hours,
};
export default function getDelay(delayStr) {
if (DELAYS[delayStr]) return DELAYS[delayStr];
return 0;
}

View file

@ -0,0 +1,23 @@
CREATE SEQUENCE catalog_id_seq;
CREATE TABLE catalog (
id bigint NOT NULL DEFAULT nextval('catalog_id_seq') PRIMARY KEY,
name varchar(255) DEFAULT NULL,
class varchar(255) DEFAULT NULL,
image varchar(255) DEFAULT NULL,
"path" varchar(255) DEFAULT NULL,
description varchar(1023) DEFAULT NULL,
type varchar(31) DEFAULT NULL,
created TIMESTAMP NOT NULL DEFAULT now(),
mr varchar(255) DEFAULT NULL,
tags varchar(255)[] DEFAULT NULL,
crons varchar(127) DEFAULT NULL,
env varchar(31)[] DEFAULT NULL,
regions varchar(15)[] DEFAULT NULL,
triggers varchar(255)[] DEFAULT NULL,
pipeline BOOLEAN DEFAULT FALSE,
coverage varchar(255)[] DEFAULT NULL,
projects varchar(255)[] DEFAULT NULL,
delay varchar(31) DEFAULT NULL,
CONSTRAINT unique_name UNIQUE(name)
);
ALTER SEQUENCE catalog_id_seq OWNED BY catalog.id;

View file

@ -1,14 +0,0 @@
CREATE SEQUENCE test_results_id_seq;
CREATE TABLE test_results (
id bigint NOT NULL DEFAULT nextval('test_results_id_seq') PRIMARY KEY,
name varchar(255) DEFAULT NULL,
"method" varchar(255) DEFAULT NULL,
env varchar(31) DEFAULT NULL,
"timestamp" TIMESTAMP NOT NULL DEFAULT now(),
retry BOOLEAN DEFAULT FALSE,
failed BOOLEAN DEFAULT FALSE,
failed_message varchar(2047) DEFAULT NULL,
screenshot varchar(255) DEFAULT NULL,
weblog varchar(255) DEFAULT NULL
);
ALTER SEQUENCE test_results_id_seq OWNED BY test_results.id;

View file

@ -1,18 +0,0 @@
CREATE SEQUENCE test_catalog_id_seq;
CREATE TABLE test_catalog (
id bigint NOT NULL DEFAULT nextval('test_catalog_id_seq') PRIMARY KEY,
name varchar(255) DEFAULT NULL,
class varchar(255) DEFAULT NULL,
compound BOOLEAN DEFAULT FALSE,
type varchar(31) DEFAULT NULL,
markers varchar(255)[] DEFAULT NULL,
ignored BOOLEAN DEFAULT FALSE,
comment varchar(1023) DEFAULT NULL,
coverage varchar(255)[] DEFAULT NULL,
env varchar(31)[] DEFAULT NULL,
"path" varchar(255) DEFAULT NULL,
regions varchar(15)[] DEFAULT NULL,
origin varchar(255) DEFAULT NULL,
cron varchar(127) DEFAULT NULL
);
ALTER SEQUENCE test_catalog_id_seq OWNED BY test_catalog.id;

View file

@ -0,0 +1,15 @@
CREATE SEQUENCE results_id_seq;
CREATE TABLE results (
id bigint NOT NULL DEFAULT nextval('results_id_seq') PRIMARY KEY,
name varchar(255) DEFAULT NULL,
class varchar(255) DEFAULT NULL,
"method" varchar(255) DEFAULT NULL,
env varchar(31) DEFAULT NULL,
"timestamp" TIMESTAMP NOT NULL DEFAULT now(),
triage BOOLEAN DEFAULT FALSE,
failed BOOLEAN DEFAULT FALSE,
message varchar(2047) DEFAULT NULL,
screenshot varchar(255) DEFAULT NULL,
console varchar(255) DEFAULT NULL
);
ALTER SEQUENCE results_id_seq OWNED BY results.id;

View file

@ -0,0 +1,9 @@
CREATE SEQUENCE alerting_id_seq;
CREATE TABLE alerting (
id bigint NOT NULL DEFAULT nextval('alerting_id_seq') PRIMARY KEY,
name varchar(255) DEFAULT NULL,
class varchar(255) DEFAULT NULL,
"method" varchar(255) DEFAULT NULL,
expires TIMESTAMP NOT NULL DEFAULT now()
);
ALTER SEQUENCE alerting_id_seq OWNED BY alerting.id;

View file

@ -52,7 +52,8 @@ export const deleteQuery = (table, jsEntry) => {
const conditionals = [];
for (var col of cols) {
entry[col] = buildPostgresValue(entry[col]);
conditionals.push(`x.${col}=${entry[col]}`);
if (entry[col] === "null") conditionals.push(`x.${col} IS NULL`);
else conditionals.push(`x.${col}=${entry[col]}`);
}
return `DELETE FROM ${table} x WHERE ${conditionals.join(" AND ")}`;
};

View file

@ -2,15 +2,15 @@
import { migrate } from "postgres-migrations";
import createPgp from "pg-promise";
import moment from "moment";
import { INFO, WARN } from "../util/logging.js";
import { INFO, WARN, OK, VERB } from "../util/logging.js";
// Environment Variables
const {
POSTGRES_DATABASE: database,
POSTGRES_DISABLED: pgDisabled,
POSTGRES_HOST: host,
POSTGRES_PASSWORD: password,
POSTGRES_PORT: port,
POSTGRES_USER: user,
QUALITEER_POSTGRES_DATABASE: database,
QUALITEER_POSTGRES_ENABLED: pgEnabled,
QUALITEER_POSTGRES_HOST: host,
QUALITEER_POSTGRES_PASSWORD: password,
QUALITEER_POSTGRES_PORT: port,
QUALITEER_POSTGRES_USER: user,
} = process.env;
// Postgres-promise Configuration
@ -34,20 +34,26 @@ const migrationsDir = "lib/database/migrations";
const queryMock = (str) => INFO("POSTGRES MOCK", str);
const connect = (pg) => async () => {
if (pgDisabled) {
if (pgEnabled === "false") {
WARN("POSTGRES", "Postgres Disabled!");
return { query: queryMock };
}
VERB("POSTGRES", "Migrating...");
await migrate(dbConfig, migrationsDir);
// Override the global variable DB
pg = pgp(dbConfig);
// Override fake methods
const pgInstance = pgp(dbConfig);
for (var k in pgInstance) pg[k] = pgInstance[k];
VERB("POSTGRES", "Migrated Successfully");
await pg.connect();
OK("POSTGRES", `Connected to database ${database}!`);
VERB("POSTGRES", "Postgres connected Successfully!");
OK("POSTGRES", `Connected to database ${dbConfig.database}!`);
};
const buildPostgres = () => {
var pg = { query: queryMock, connect: connect(pg) };
var pg = { query: queryMock };
pg.connect = connect(pg);
return pg;
};
export default buildPostgres;
export default buildPostgres();

View file

@ -1,18 +1,56 @@
import pg from "../postgres.js";
import { silencedMock } from "../mocks/alerting-mock.js";
import moment from "moment";
// Imports
import {
insertQuery,
selectWhereAnyQuery,
updateWhereAnyQuery,
deleteQuery,
} from "../pg-query.js";
// Constants
const table = "silenced_tests";
const table = "alerting";
const PG_DISABLED = process.env.POSTGRES_DISABLED;
export const upsertAlertSilence = async (silence) => {
const {
id,
name,
class: className,
method,
expires: duration,
keepExpires,
} = silence;
const { h, m } = duration;
const expires = moment().add(h, "hours").add(m, "minutes").utc().format();
const entry = {
name,
class: className,
method,
expires: keepExpires ? undefined : expires,
};
const asUpdate = {};
for (var k of Object.keys(entry))
asUpdate[k] = entry[k] === "*" ? null : entry[k];
var query = id
? updateWhereAnyQuery(table, asUpdate, { id })
: insertQuery(table, entry);
return pg.query(query);
};
export const deleteAlertSilence = async (silence) => {
const { id } = silence;
const query = deleteQuery(table, { id });
return pg.query(query);
};
// Queries
export const getSilencedTests = async () => {
if (PG_DISABLED) return silencedMock();
const query = `SELECT * from ${table}`;
return pg.query(query);
const silenced = await pg.query(query);
silenced.forEach((t, i) => {
for (var k of Object.keys(t)) silenced[i][k] = t[k] === null ? "*" : t[k];
});
return silenced;
};

View file

@ -3,10 +3,13 @@ import pg from "../postgres.js";
import {
insertQuery,
selectWhereAnyQuery,
updateWhereAnyQuery,
onConflictUpdate,
} from "../pg-query.js";
import getFilteredTags from "../tags.js";
import getDelay from "../delays.js";
// Constants
const table = "tests";
const table = "catalog";
const PG_DISABLED = process.env.POSTGRES_DISABLED;
import { testsMock, mappingsMock } from "../mocks/catalog-mock.js";
// Queries
@ -19,8 +22,30 @@ export const getTests = async () => {
export const getPipelineMappings = async () => {
if (PG_DISABLED) return mappingsMock();
const query = `SELECT * from ${table}`;
return pg.query(query);
const query = `SELECT * from ${table} WHERE pipeline`;
const tests = await pg.query(query);
const mappings = [];
var newTrigger;
for (var test of tests) {
if (test.triggers) continue;
const { name, delay: delayStr } = test;
var triggerStack = [{ name, delay: getDelay(delayStr), delayStr }];
newTrigger = { name, delayStr };
while (
(newTrigger = tests.find(
(te) => te.triggers && te.triggers.includes(newTrigger.name)
)) !== null
) {
if (!newTrigger) break;
triggerStack.push({
name: newTrigger.name,
delay: getDelay(newTrigger.delay),
delayStr: newTrigger.delay,
});
}
mappings.push(triggerStack.reverse());
}
return mappings;
};
export const getProjects = async () => {
@ -28,3 +53,47 @@ export const getProjects = async () => {
const tests = testsMock();
}
};
export const upsertTest = async (test) => {
if (PG_DISABLED) return console.log("Would insert test", test);
const {
name,
class: className,
image,
path,
description,
type,
created,
mergeRequest,
tags,
} = test;
const filteredTags = getFilteredTags(tags);
const env =
filteredTags.ignore && filteredTags.env
? filteredTags.env.filter((e) => !filteredTags.ignore.includes(e))
: filteredTags.env;
const catalogEntry = {
name,
class: className,
image,
path,
description: description ? description : null,
type,
created,
mr: mergeRequest,
tags,
crons: filteredTags.crons,
env,
regions: filteredTags.regions,
triggers: filteredTags.triggers,
pipeline: filteredTags.pipeline ? true : false,
coverage: filteredTags.coverage,
projects: filteredTags.projects,
delay: filteredTags.delay ? filteredTags.delay[0] : null,
};
const query =
insertQuery(table, catalogEntry) + onConflictUpdate(["name"], catalogEntry);
return await pg.query(query);
};

View file

@ -8,41 +8,36 @@ import {
updateWhereAnyQuery,
} from "../pg-query.js";
// Constants
const table = "test_results";
const table = "results";
const recentResultsMax = 5;
const PG_DISABLED = process.env.POSTGRES_DISABLED;
// Queries
export const insertTestResult = (testResult) => {
const {
test_name,
test_class,
test_method,
test_path,
test_type,
test_timestamp,
test_retry,
origin,
name,
class: className,
method,
env,
timestamp,
triage,
failed,
failed_message,
screenshot_url,
expected_screenshot_url,
weblog_url,
message,
screenshot,
console: cs,
} = testResult;
var query = insertQuery(table, {
test_name,
test_class,
test_method,
test_path,
test_type,
test_timestamp,
test_retry,
origin,
name,
class: className,
method,
env,
timestamp,
triage,
failed,
failed_message,
screenshot_url,
expected_screenshot_url,
weblog_url,
message,
screenshot,
console: cs,
});
query += "\n RETURNING *";
@ -51,19 +46,48 @@ export const insertTestResult = (testResult) => {
export const getCurrentlyFailing = async () => {
if (PG_DISABLED) return failingMock();
/**/
const query = `WITH recent as (SELECT * FROM test_results WHERE (timestamp BETWEEN NOW() - INTERVAL '24 HOURS' AND NOW()) AND NOT(failed AND retry)) SELECT * FROM recent WHERE timestamp = (SELECT MAX(timestamp) FROM recent r2 WHERE recent.name = r2.name) AND failed;
`;
return pg.query(query);
/* This can probably be changed into a super query, but perhaps faster/smaller */
const recent = `SELECT * FROM ${table} WHERE (timestamp BETWEEN NOW() - INTERVAL '24 HOURS' AND NOW()) AND NOT(failed AND triage)`;
const slimCatalog = `SELECT name, crons, class, type, pipeline, env AS enabled_env FROM catalog`;
const failing = `SELECT * FROM recent INNER JOIN slim_catalog USING(name) WHERE timestamp = (SELECT MAX(timestamp) FROM recent r2 WHERE recent.name = r2.name) AND failed`;
const applicableFailing = `SELECT name, count(*) as fails FROM recent WHERE recent.name IN (SELECT name FROM failing) GROUP BY name`;
/*const runHistory = `SELECT name, timestamp, failed FROM (SELECT *, ROW_NUMBER() OVER(PARTITION BY name ORDER BY timestamp) as n
FROM ${table} WHERE name IN (SELECT name FROM failing)) as ord WHERE n <= ${recentResultsMax} ORDER BY name DESC`;*/
const runHistory = `SELECT name, timestamp, failed FROM results WHERE NOT triage AND name IN (SELECT name FROM failing) ORDER BY timestamp DESC LIMIT ${recentResultsMax}`;
// const recentQuery = pg.query(recent);
const failingQuery = pg.query(
`WITH recent as (${recent}), slim_catalog as (${slimCatalog}) ${failing}`
);
const applicableQuery = pg.query(
`WITH recent as (${recent}), slim_catalog as (${slimCatalog}), failing as (${failing}) ${applicableFailing}`
);
const historyQuery = pg.query(
`WITH recent as (${recent}), slim_catalog as (${slimCatalog}), failing as (${failing}) ${runHistory}`
);
/*SELECT * FROM test_results WHERE "timestamp" BETWEEN NOW() - INTERVAL '24 HOURS' AND NOW(); <-- Last 24 hours all runs*/
/* SELECT * FROM test_results tr1 WHERE timestamp BETWEEN NOW() - INTERVAL '24 HOURS' AND NOW() AND timestamp = (SELECT MAX(timestamp) FROM test_results tr2 WHERE tr1.name = tr2.name); <-- Last 24 hours only most recent
*/
const [currentlyFailing, applicableFails, failHistory] = await Promise.all([
failingQuery,
applicableQuery,
historyQuery,
]);
for (var i = 0; i < currentlyFailing.length; i++) {
currentlyFailing[i].dailyFails = parseInt(
applicableFails.find((af) => af.name === currentlyFailing[i].name).fails
);
currentlyFailing[i].recentResults = [];
currentlyFailing[i].enabledEnv = currentlyFailing[i].enabled_env;
currentlyFailing[i].isPipeline = currentlyFailing[i].pipeline;
delete currentlyFailing[i].enabled_env;
delete currentlyFailing[i].pipeline;
for (var fh of failHistory) {
if (fh.name !== currentlyFailing[i].name) continue;
currentlyFailing[i].recentResults.push(fh);
}
}
return currentlyFailing;
};
export const getCurrentlyFailingFull = (env) => {
const query = `WITH recent AS (SELECT * FROM test_results WHERE (timestamp BETWEEN NOW() - INTERVAL '24 HOURS' AND NOW()) AND NOT(failed AND retry) AND env='prod') SELECT * FROM recent INNER JOIN test_catalog USING(name) WHERE timestamp = (SELECT MAX(timestamp) FROM recent r2 WHERE recent.name = r2.name) AND failed;
;`;
export const ignoreResult = async ({ id }) => {
const query = updateWhereAnyQuery(table, { failed: false }, { id });
return pg.query(query);
};

30
lib/database/seed.js Normal file
View file

@ -0,0 +1,30 @@
import pg from "./postgres.js";
import { upsertTest } from "./queries/catalog.js";
import { insertTestResult } from "./queries/results.js";
import { upsertAlertSilence } from "./queries/alerting.js";
import {
seed as catalogSeed,
table as catalogTable,
} from "./seeds/catalog-seed.js";
import {
seed as resultsSeed,
table as resultsTable,
} from "./seeds/results-seed.js";
import {
seed as alertingSeed,
table as alertingTable,
} from "./seeds/alerting-seed.js";
const database = process.env.POSTGRES_DATABASE ?? "qualiteer";
await pg.connect();
const resetAndSeed = async (table, getSeeds, seed) => {
await pg.query(`TRUNCATE ${table} RESTART IDENTITY CASCADE;`);
for (var s of getSeeds()) await seed(s);
};
await resetAndSeed(catalogTable, catalogSeed, upsertTest);
await resetAndSeed(resultsTable, resultsSeed, insertTestResult);
await resetAndSeed(alertingTable, alertingSeed, upsertAlertSilence);
process.exit();

View file

@ -0,0 +1,11 @@
export const table = "alerting";
export const seed = () => {
return [
{
name: `failing`,
class: `failing.js`,
method: "FAKEMETHOD",
expires: new Date().toJSON(),
},
];
};

View file

@ -0,0 +1,126 @@
export const table = "catalog";
export const seed = () => {
return [
{
name: "single",
class: "single.js",
image: "node:latest",
path: "tests/assets/suite/single.js",
description: "This is a single test",
type: "api",
created: new Date().toJSON(),
mergeRequest: "https://example.com",
tags: ["cron_1hour", "reg_us", "env_ci", "proj_core", "ignore_alt"],
},
{
name: "failing",
class: "failing.js",
image: "node:latest",
path: "tests/assets/suite/failing.js",
description: "This is a failing test",
type: "ui",
created: new Date().toJSON(),
mergeRequest: "https://example.com",
tags: ["cron_1hour", "reg_us", "env_ci", "proj_core"],
},
{
name: "primary",
class: "primary.js",
image: "node:latest",
path: "tests/assets/suite/primary.js",
description: "This is a primary test",
type: "api",
created: new Date().toJSON(),
mergeRequest: "https://example.com",
tags: [
"pipeline",
"cron_1hour",
"reg_us",
"proj_core",
"ignore_alt",
"triggers_secondary1",
"triggers_secondary2",
],
},
{
name: "secondary1",
class: "secondary1.js",
image: "node:latest",
path: "tests/assets/suite/secondary1.js",
description: "This is a secondary test",
type: "api",
created: new Date().toJSON(),
mergeRequest: "https://example.com",
tags: [
"pipeline",
"cron_1hour",
"reg_us",
"proj_core",
"triggers_tertiary1",
"triggers_tertiary2",
"delay_1sec",
],
},
{
name: "secondary2",
class: "secondary2.js",
image: "node:latest",
path: "tests/assets/suite/secondary2.js",
description: "This is a secondary2 test",
type: "api",
created: new Date().toJSON(),
mergeRequest: "https://example.com",
tags: [
"pipeline",
"cron_1hour",
"reg_us",
"proj_core",
"triggers_tertiary3",
],
},
{
name: "tertiary1",
class: "tertiary1.js",
image: "node:latest",
path: "tests/assets/suite/tertiary1.js",
description: "This is a third test",
type: "api",
created: new Date().toJSON(),
mergeRequest: "https://example.com",
tags: ["pipeline", "cron_1hour", "reg_us", "proj_core"],
},
{
name: "tertiary2",
class: "tertiary2.js",
image: "node:latest",
path: "tests/assets/suite/tertiary2.js",
description: "This is a third2 test",
type: "api",
created: new Date().toJSON(),
mergeRequest: "https://example.com",
tags: ["pipeline", "cron_1hour", "reg_us", "proj_core", "delay_10sec"],
},
{
name: "tertiary3",
class: "tertiary3.js",
image: "node:latest",
path: "tests/assets/suite/tertiary3.js",
description: "This is a third3 test",
type: "api",
created: new Date().toJSON(),
mergeRequest: "https://example.com",
tags: ["pipeline", "cron_1hour", "reg_us", "proj_core", "delay_5sec"],
},
{
name: "single-alt",
class: "single-alt.js",
image: "node:latest",
path: "tests/assets/suite/single-alt.js",
description: "This is an alternative test",
type: "ui",
created: new Date().toJSON(),
mergeRequest: "https://example.com",
tags: ["cron_1hour", "reg_us", "env_ci", "proj_alt"],
},
];
};

View file

@ -0,0 +1,29 @@
export const table = "results";
export const seed = () => {
return [
{
name: "failing",
class: "failing.js",
method: "FAKEMETHOD",
env: "prod",
timestamp: new Date().toJSON(),
triage: false,
failed: true,
message: "Some Test FailureMessage",
screenshot: "https://picsum.photos/1920/1080",
console: "https://example.com",
},
{
name: "secondary1",
class: "secondary1.js",
method: "FAKEMETHOD",
env: "prod",
timestamp: new Date().toJSON(),
triage: false,
failed: true,
message: "Some Test FailureMessage from Secondary1",
screenshot: "https://picsum.photos/1920/1080",
console: "https://example.com",
},
];
};

26
lib/database/tags.js Normal file
View file

@ -0,0 +1,26 @@
import { WARN } from "../util/logging.js";
export const TAGS = {
IGNORE: { name: "ignore", tag: "ignore_", value: (t) => t },
CRON: { name: "crons", tag: "cron_", value: (t) => t },
ENV: { name: "env", tag: "env_", value: (t) => t },
REGIONS: { name: "regions", tag: "reg_", value: (t) => t },
PIPELINE: { name: "pipeline", tag: "pipeline", value: (t) => t },
COVERAGE: { name: "coverage", tag: "coverage_", value: (t) => t },
PROJECT: { name: "projects", tag: "proj_", value: (t) => t },
DELAY: { name: "delay", tag: "delay_", value: (t) => t },
TRIGGERS: { name: "triggers", tag: "triggers_", value: (t) => t },
};
export default function getFilteredTags(tags) {
const filtered = {};
for (var t of tags) {
const tag = Object.values(TAGS).find((ta) => t.startsWith(ta.tag));
if (!tag) {
WARN("CATALOG", `Tag '${t}' did not have a valid prefix!`);
continue;
}
if (!filtered[tag.name]) filtered[tag.name] = [];
filtered[tag.name].push(tag.value(t.replace(tag.tag, "")));
}
return filtered;
}

View file

@ -27,6 +27,8 @@ class JobManager {
pushLog(jobId, log) {
const job = this.getJobById(jobId);
if (!job) return;
if (log instanceof Array) job.log.push(...log);
else job.log.push(log);
}

View file

@ -1,11 +1,5 @@
const baseCommand = "node";
const suiteEntry = "tests/assets/suite/runner.js";
const pipelineMapping = [
{
id: 0,
pipeline: [{ name: "primary" }, { name: "secondary", delay: 5000 }],
},
];
const buildCommon = (jobRequest) => {
const { isTriage, ignore, region, testNames } = jobRequest;
@ -25,7 +19,6 @@ const buildManual = (jobReq) => {
throw Error("Currently only 1 test can be selected!");
command.push(`test=${testNames[0]}`);
return { ...jobReq, command };
};
@ -51,7 +44,6 @@ const buildPipeline = (jobReq, socketId) => {
};
export default function jobBuilder(jobRequest, id) {
console.log(jobRequest);
const jobReq = buildCommon(jobRequest, id);
const { pipeline, testNames, tags } = jobReq;
if (pipeline) return buildPipeline(jobReq, id);

View file

@ -5,22 +5,13 @@
"name": "qltr-job-test-suite-1"
},
"spec": {
"ttlSecondsAfterFinished": 2,
"template": {
"spec": {
"containers": [
{
"resources": {
"requests": {
"memory": "64MI",
"cpu": "250m"
},
"limits": {
"memory": "128MI",
"cpu": "500m"
}
},
"name": "qltr-job-test-suite-1",
"image": "node",
"image": "node:latest",
"imagePullPolicy": "Always",
"command": ["node", "--version"]
}

View file

@ -1,16 +1,24 @@
import cp from "node:child_process";
import k8s from "@kubernetes/client-node";
import { INFO, ERR } from "../../util/logging.js";
import { jobBuilder, createFile, deleteFile } from "./k8s-common.js";
const applyFile = async (filePath) => {
const command = `kubectl apply -f ${filePath}`;
return new Promise((res, rej) =>
cp.exec(command, (err, stdout, stderr) => (err && rej(err)) || res(stdout))
);
};
export default async function createJob(jobRequest) {
const job = jobBuilder(jobRequest);
const filePath = createFile(job);
job.spec.template.spec.containers[0].image = "node:latest";
job.spec.template.spec.containers[0].command = ["node", "--version"];
// job.spec.template.spec.containers[0].image = "reed";
// job.spec.template.spec.containers[0].command = "python3 -m pytest -v --tb=no -p no:warnings".split(" ");
const kc = new k8s.KubeConfig();
kc.loadFromCluster();
const batchV1Api = kc.makeApiClient(k8s.BatchV1Api);
const batchV1beta1Api = kc.makeApiClient(k8s.BatchV1beta1Api);
const jobName = job.metadata.name;
batchV1Api
.createNamespacedJob("dunestorm-dunemask", job)
.then((res) => INFO("K8S", `Job ${jobName} created!`))
.catch((err) => ERR("K8S", err));
/*const filePath = createFile(job);
applyFile(filePath);
deleteFile(filePath);
deleteFile(filePath);*/
}

View file

@ -2,16 +2,21 @@ import Rabbiteer from "rabbiteer";
import buildWorkers from "./workers/index.js";
// Pull Environment Variables
const { RABBIT_HOST: host, RABBIT_USER: user, RABBIT_PASS: pass } = process.env;
const {
QUALITEER_RABBIT_HOST: host,
QUALITEER_RABBIT_USER: user,
QUALITEER_RABBIT_PASS: pass,
} = process.env;
// Rabbit Config
const rabbitConfig = {
host: host ?? "localhost",
user: user ?? "rabbit",
pass: pass ?? "rabbit",
protocol: "amqp:",
host: `amqp://${host}` ?? "localhost",
user: user ?? "guest",
pass: pass ?? "guest",
};
const buildRabbiteer = (skio) =>
const buildRabbiteer = (pg, skio) =>
new Rabbiteer(null, buildWorkers(skio), { autoRabbit: rabbitConfig });
export default buildRabbiteer;

View file

@ -1,5 +1,7 @@
// Imports
import { Worker } from "rabbiteer";
import { VERB } from "../../util/logging.js";
import { insertTestResult } from "../../database/queries/results.js";
import evt from "../../sockets/events.js";
// Class
export default class TestResultsWorker extends Worker {
@ -23,9 +25,9 @@ export default class TestResultsWorker extends Worker {
consoleLogUrl: https://consolelog”
}
*/
onMessage(testResult) {
async onMessage(testResult) {
const { pipeline } = testResult;
await this.handleReporting(testResult);
// Alter to start next test
// TODO the delay should be autopopulated either by the suite, or filled in by the server
if (pipeline) return this.pipelineTrigger(pipeline);
@ -35,4 +37,9 @@ export default class TestResultsWorker extends Worker {
const { dashboardSocketId: dsi } = pipeline;
this.skio.to(dsi).emit(evt.PPL_TRG, pipeline);
}
handleReporting(result) {
VERB("TestResults", result.name);
insertTestResult(result);
}
}

View file

@ -1,5 +1,9 @@
import { Router, json as jsonMiddleware } from "express";
import { getSilencedTests } from "../database/queries/alerting.js";
import {
getSilencedTests,
upsertAlertSilence,
deleteAlertSilence,
} from "../database/queries/alerting.js";
const router = Router();
// Apply Middlewares
@ -12,7 +16,26 @@ router.get("/silenced", (req, res) => {
// Post Routes
router.post("/silence", (req, res) => {
res.sendStatus(200);
const { name, class: className, method, expires, keepExpires } = req.body;
if (!name || !className || !method)
return res
.status(400)
.send("'name', 'class', and 'method' are all required Fields!");
if (expires === null)
return deleteAlertSilence(req.body)
.then(() => res.sendStatus(200))
.catch((e) => res.status(500).send(e));
const { h, m } = keepExpires ? {} : expires;
if (!keepExpires && (h == null || m == null))
return res.status(400).send("Both 'h' and 'm' are required fields!");
if (!keepExpires && (h < 0 || m < 0))
return res
.status(400)
.send("'h' and 'm' must be greater than or equal to 0!");
// TODO set max times as well
if (!keepExpires && (h > 72 || m > 59))
res.status(400).send("'h' and 'm' must not exceed the set maxes!");
upsertAlertSilence(req.body).then(() => res.sendStatus(200));
});
export default router;

View file

@ -1,5 +1,9 @@
import { Router, json as jsonMiddleware } from "express";
import { getTests, getPipelineMappings } from "../database/queries/catalog.js";
import {
getTests,
getPipelineMappings,
upsertTest,
} from "../database/queries/catalog.js";
const router = Router();
const maxSize = 1024 * 1024 * 100; // 100MB
@ -18,8 +22,10 @@ router.get("/pipeline-mappings", (req, res) => {
// Post Routes
router.post("/update", (req, res) => {
// Update All Tests
res.sendStatus(200);
if (!req.body) return res.status(400).send("Body required!");
if(!Array.isArray(req.body)) return res.status(400).send("Body must be an array!");
const upserts = Promise.all(req.body.map((catalogItem)=>upsertTest(catalogItem)));
upserts.then(()=>res.sendStatus(200)).catch((e)=>res.status(500).send(e));
});
export default router;

View file

@ -1,5 +1,8 @@
import { Router, json as jsonMiddleware } from "express";
import { getCurrentlyFailing } from "../database/queries/results.js";
import {
getCurrentlyFailing,
ignoreResult,
} from "../database/queries/results.js";
const router = Router();
// Apply Middlewares
@ -15,4 +18,10 @@ router.post("/history", (req, res) => {
res.send([]);
});
router.post("/ignore", (req, res) => {
if (!req.body || !req.body.id)
return res.status(400).send("'id' is required!");
ignoreResult(req.body).then(() => res.sendStatus(200));
});
export default router;

View file

@ -2,6 +2,7 @@
import express from "express";
// Routes
import vitals from "../routes/vitals-route.js";
import results from "../routes/results-route.js";
import alerting from "../routes/alerting-route.js";
import react from "../routes/react-route.js";
@ -12,6 +13,7 @@ import buildDevRoute from "../routes/dev-route.js";
export default function buildRoutes(pg, skio) {
const router = express.Router();
// Special Routes
router.use(vitals);
router.all("/", (req, res) => res.redirect("/qualiteer"));
if (process.env.USE_DEV_ROUTER === "true")
router.use("/api/dev", buildDevRoute(pg, skio));

View file

@ -0,0 +1,7 @@
import { Router } from "express";
const router = Router();
// Get Routes
router.get("/healthz", (req, res) => res.sendStatus(200));
export default router;

View file

@ -13,7 +13,7 @@ const OUT = "o";
export default class Executor {
constructor(config, payload) {
this.url = config.url(payload) ?? process.env.QUALITEER_URL;
this.url = config.url(payload) ?? process.env.QUALITEER_EXECUTOR_URL;
this.jobId = config.jobId(payload) ?? process.env.QUALITEER_JOB_ID;
this.command = config.command(payload) ?? process.env.QUALITEER_COMMAND;
this.mode = modes.EXEC;

5643
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,18 +14,18 @@
"qualiteer": "./dist/app.js"
},
"scripts": {
"build:all": "npm run build:react && npm run build:executor",
"build:all": "concurrently \"npm run build:react\" \"npm run build:executor\" -n v,s -p -c yellow,green",
"build:executor": "node lib/jobs/executor/executor-bundler.js",
"build:react": "vite build",
"start": "node dist/app.js",
"start:dev": "nodemon dist/app.js",
"start:dev:replit": "npm run start:react:replit & (sleep 30 && npm run start:dev)",
"start:react": "vite preview",
"start:react:replit": "vite --host",
"dev:server": "nodemon dist/app.js",
"dev:react": "vite",
"start:dev": "concurrently -k \"QUALITEER_DEV_PORT=52025 npm run dev:server\" \" QUALITEER_VITE_DEV_PORT=52000 QUALITEER_VITE_BACKEND_URL=http://localhost:52025 npm run dev:react\" -n s,v -p -c green,yellow",
"test": "node tests/index.js",
"test:api": "node tests/api.js",
"test:dev": "nodemon tests/index.js",
"lint": "prettier -w lib/ src/"
"lint": "prettier -w lib/ src/",
"seed": "node lib/database/seed.js"
},
"browserslist": {
"production": [
@ -40,44 +40,45 @@
]
},
"dependencies": {
"amqplib": "^0.8.0",
"@kubernetes/client-node": "^0.17.0",
"amqplib": "^0.10.3",
"chalk": "^5.0.1",
"dotenv": "^16.0.0",
"express": "^4.17.2",
"dotenv": "^16.0.2",
"express": "^4.18.1",
"figlet": "^1.5.2",
"lodash": "^4.17.21",
"moment": "^2.29.3",
"moment": "^2.29.4",
"path": "^0.12.7",
"pg-promise": "^10.11.1",
"pg-promise": "^10.12.0",
"postgres-migrations": "^5.3.0",
"socket.io": "^4.4.1",
"socket.io-client": "^4.4.1",
"uuid": "^8.3.2"
"socket.io": "^4.5.2",
"socket.io-client": "^4.5.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^5.6.2",
"@mui/material": "^5.6.4",
"@rollup/plugin-commonjs": "^22.0.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4",
"@mui/icons-material": "^5.10.3",
"@mui/material": "^5.10.4",
"@rollup/plugin-commonjs": "^22.0.2",
"@rollup/plugin-node-resolve": "^14.0.0",
"@rollup/plugin-replace": "^4.0.0",
"@tanstack/react-query": "^4.0.0",
"@vitejs/plugin-react": "1.1.1",
"@tanstack/react-query": "^4.2.3",
"@vitejs/plugin-react": "2.1.0",
"axios": "^0.27.2",
"caxa": "^2.1.0",
"nodemon": "^2.0.15",
"concurrently": "^7.3.0",
"nodemon": "^2.0.19",
"prettier": "^2.7.1",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.3.0",
"readline-sync": "^1.4.10",
"rollup": "^2.72.0",
"rollup": "^2.79.0",
"rollup-plugin-terser": "^7.0.2",
"vite": "2.7.0"
"vite": "3.1.0"
},
"optionalDependencies": {
"rabbiteer": "gitlab:Dunemask/rabbiteer"
},
"proxy": "http://localhost:52000/"
}
}

View file

@ -1,10 +0,0 @@
{ pkgs }: {
deps = [
pkgs.cloc
pkgs.nodejs-16_x
pkgs.nodePackages.typescript-language-server
pkgs.nodePackages.yarn
pkgs.replitPackages.jest
pkgs.vim
];
}

View file

@ -18,7 +18,7 @@ const ACTIONS = {
PIPELINE: "p",
};
const url = "https://qualiteer.elijahparker3.repl.co/";
const url = "/";
const initialState = {
jobs: [],
@ -134,7 +134,7 @@ export const JobProvider = ({ children }) => {
request,
onLog,
onClose,
null,
() => {},
onPipelineTrigger
);
started.then(() => jobUpdate({ status: jobStatus.ACTIVE }, jobId));
@ -146,6 +146,7 @@ export const JobProvider = ({ children }) => {
const pipelineReq = {
image: "node",
pipeline: { __test, triggers: { ...tree[__test] } },
isTriage: builderCache.triageFailing,
};
const id = `pij${Date.now()}`;
const pipeline = { id, branches, pendingTriggers: [], selectedBranches };
@ -232,6 +233,7 @@ export const JobProvider = ({ children }) => {
image: "node",
type: "single",
name: jobId,
isTriage: builderCache.isTriage,
};
jobCreate(job);
@ -250,7 +252,7 @@ export const JobProvider = ({ children }) => {
jobUpdate({ ...job }, jobId);
};
const started = i.newJob(request, onLog, onClose);
const started = i.newJob(request, onLog, onClose, () => {});
started.then(() => jobUpdate({ status: jobStatus.ACTIVE }, jobId));
return jobId;

View file

@ -5,7 +5,7 @@ const ACTIONS = {
UPDATE: "u",
};
const localStorage = { setItem: () => {}, getItem: () => {} };
// const localStorage = { setItem: () => {}, getItem: () => {} };
const localSettings = localStorage.getItem("settings");
const defaultSettings = {
@ -14,6 +14,7 @@ const defaultSettings = {
logAppDetails: true,
defaultRegion: "us",
defaultPage: "failing",
triageFailing: true,
};
const settings = localSettings ? JSON.parse(localSettings) : defaultSettings;
@ -58,7 +59,7 @@ export const StoreProvider = ({ children }) => {
name: silenceInfo.name ?? "*",
class: silenceInfo.class ?? "*",
method: silenceInfo.method ?? "*",
silencedUntil: silenceInfo.silencedUntil,
expires: silenceInfo.expires,
};
console.log("Would upsert silence", req);
}

View file

@ -1,11 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { silencedMock } from "@qltr/mocks/alerting-mock.js";
import { failingMock } from "@qltr/mocks/results-mock.js";
import { testsMock, mappingsMock } from "@qltr/mocks/catalog-mock.js";
const QUALITEER_URL = "https://qualiteer.elijahparker3.repl.co/api";
const QUALITEER_URL = "/api";
const useMock = true;
const useMock = false;
const asMock = (data) => ({ data });
@ -31,3 +31,44 @@ export const useCurrentlyFailing = () =>
useMock
? asMock(failingMock())
: useQuery(["failing"], fetchApi("/results/failing"));
export const useUpsertAlert = () => {
const qc = useQueryClient();
return async function upsertAlert(silenceRequest) {
var { id, name, class: className, method, expires } = silenceRequest;
const keepExpires = typeof expires === "string";
name = name ? name : "*";
className = className ? className : "*";
method = method ? method : "*";
const res = await fetch(`${QUALITEER_URL}/alerting/silence`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
name,
class: className,
method,
expires,
keepExpires,
}),
});
qc.invalidateQueries(["silenced"]);
};
};
export const useIgnoreResult = () => {
const qc = useQueryClient();
return async function ignoreResult(result) {
const { id } = result;
const res = await fetch(`${QUALITEER_URL}/results/ignore`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id }),
});
qc.invalidateQueries(["failing"]);
};
};

View file

@ -17,7 +17,7 @@ export default function About() {
</Typography>
<Typography variant="body1">
Qualiteer was designed to solve the issue of "on call". A state of
being in which QA tests will fail, stiring everyone into a frenzy of
being in which QA tests will fail, stirring everyone into a frenzy of
what is broken in production! 🤯 Qualiteer gives users power to
resolve and reattempt failing tests, run a particular suite of tests,
and mute pesky alerts reminding you the navbar's color changed... 🤦

View file

@ -1,8 +1,8 @@
import React, { useState, useContext } from "react";
import { useSilencedAlerts } from "@qltr/queries";
import { useSilencedAlerts, useUpsertAlert } from "@qltr/queries";
import StoreContext from "@qltr/store";
import SilencedBox from "./SilencedBox.jsx";
import SilenceDialog from "./SilenceDialog.jsx";
import SilenceDialog, { useSilenceDialog } from "./SilenceDialog.jsx";
import SpeedDial from "@mui/material/SpeedDial";
import SpeedDialAction from "@mui/material/SpeedDialAction";
@ -18,41 +18,38 @@ import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
export default function Alerting() {
const [deleteOpen, setDeleteOpen] = useState(false);
const { updateStore, silenceRequest } = useContext(StoreContext);
const { isLoading, data: silenced } = useSilencedAlerts();
const upsertAlert = useUpsertAlert();
const [sdOpen, sdToggle, silence, setSilence, sdClose] = useSilenceDialog();
const toggleDelete = () => setDeleteOpen(!deleteOpen);
const [silenceEntry, setSilenceEntry] = useState({
open: false,
deleteOpen: false,
});
const closeSilence = () =>
setSilenceEntry({ ...silenceEntry, open: false, deleteOpen: false });
const handleDeleteClose = (makeRequest) => () => {
const silenceReq = { ...silenceEntry };
closeSilence();
if (!makeRequest) return;
silenceRequest({ ...silenceReq, silencedUntil: null });
const handleDeleteClose = (confirmed) => () => {
toggleDelete();
if (!confirmed) return;
const silenceReq = { ...silence };
upsertAlert({ ...silenceReq, expires: null });
};
const handleClose = (silenceReq) => {
closeSilence();
if (!silenceReq) return;
silenceRequest(silenceReq);
upsertAlert(silenceReq);
};
const quickAlertClick = () => {
setSilenceEntry({ open: true, deleteOpen: false });
setSilence({});
sdToggle();
};
const editSilence = (silence) => () => {
setSilenceEntry({ ...silence, open: true, deleteOpen: false });
sdToggle();
setSilence(silence);
};
const removeSilence = (silence) => () => {
setSilenceEntry({ ...silence, deleteOpen: true, open: false });
setSilence(silence);
toggleDelete();
};
return (
@ -93,7 +90,7 @@ export default function Alerting() {
) : null}
<Dialog
open={silenceEntry.deleteOpen}
open={deleteOpen}
onClose={handleDeleteClose()}
sx={{ "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }}
maxWidth="xs"
@ -110,9 +107,9 @@ export default function Alerting() {
<SilenceDialog
keepMounted
open={silenceEntry.open}
onClose={handleClose}
silence={silenceEntry}
open={sdOpen}
onClose={sdClose}
silence={silence}
/>
<SpeedDial

View file

@ -1,6 +1,5 @@
import { useState, useContext, useEffect } from "react";
import StoreContext from "@qltr/store";
import { useUpsertAlert } from "@qltr/queries";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button";
@ -11,25 +10,53 @@ import Dialog from "@mui/material/Dialog";
import TextField from "@mui/material/TextField";
import Toolbar from "@mui/material/Toolbar";
import SilenceSelector from "./SilenceSelector.jsx";
export function useSilenceDialog(isOpen = false) {
const [open, setOpen] = useState(isOpen);
const [silence, setSilence] = useState({});
const upsertAlert = useUpsertAlert();
const dialogToggle = () => setOpen(!open);
const dialogClose = (confirmedSilence) => {
setOpen(false);
if (!confirmedSilence) return;
upsertAlert(confirmedSilence);
setSilence({});
};
return [open, dialogToggle, silence, setSilence, dialogClose];
}
export default function SilenceDialog(props) {
const { silence, open, onClose } = props;
const [duration, setDuration] = useState({ h: 0, m: 0 });
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const [silenceEntry, setSilenceEntry] = useState(silence);
const durationModified = duration.h !== 0 || duration.m !== 0;
const silenceModified =
silence.name !== silenceEntry.name ||
silence.method !== silenceEntry.method ||
silence.class !== silenceEntry.class;
const modified =
(Object.keys(silence).length > 0 && durationModified) || silenceModified;
useEffect(() => {
setSilenceEntry(silence);
setDuration({ h: 0, m: 0 });
}, [silence, open]);
const { state: store, updateStore } = useContext(StoreContext);
const handleCancel = () => onClose();
const handleOk = () => onClose(silenceEntry);
const updateSilence = (silenceType) => (e) => {
silenceEntry[silenceType] = e.target.value;
setSilenceEntry({ ...silenceEntry });
const s = { ...silenceEntry };
s[silenceType] = e.target.value;
setSilenceEntry({ ...s, expires: duration });
};
const updateDuration = (dur) => {
setDuration(dur);
setSilenceEntry({ ...silenceEntry, expires: dur });
};
return (
@ -37,7 +64,7 @@ export default function SilenceDialog(props) {
sx={
fullScreen
? {}
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 525 } }
}
maxWidth="xs"
open={open}
@ -70,12 +97,19 @@ export default function SilenceDialog(props) {
onChange={updateSilence("class")}
margin="normal"
/>
<SilenceSelector
formerExpires={silence.expires}
duration={duration}
setDuration={updateDuration}
/>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleOk}>Ok</Button>
<Button onClick={handleOk} disabled={!modified}>
Ok
</Button>
</DialogActions>
</Dialog>
);

View file

@ -0,0 +1,70 @@
import { useState } from "react";
import moment from "moment";
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
const MAX_DURATION = { h: 72, m: 59 };
const MIN_DURATION = 0;
export default function SilenceSelector(props) {
const [expires, setExpires] = useState(moment());
const { formerExpires, duration, setDuration } = props;
const formerExpirey =
formerExpires && duration.h === 0 && duration.m === 0
? moment(formerExpires)
: null;
const updateDuration = (t) => (e) => {
const d = { ...duration };
d[t] = Math.abs(parseInt(e.target.value));
if (isNaN(d[t])) d[t] = 0;
d[t] = d[t] <= MAX_DURATION[t] ? d[t] : MAX_DURATION[t];
setDuration(d);
const now = moment().add(d.h, "hours").add(d.m, "minutes");
setExpires(now);
};
const onNumberInput = (e) =>
(e.target.value =
!!e.target.value && Math.abs(e.target.value) >= MIN_DURATION
? Math.abs(e.target.value)
: null);
return (
<Box>
<Box style={{ display: "flex" }}>
<TextField
margin="normal"
label="Hours"
type="number"
onChange={updateDuration("h")}
inputProps={{
min: MIN_DURATION,
max: MAX_DURATION.h,
onInput: onNumberInput,
}}
value={duration.h}
autoComplete="off"
/>
<TextField
margin="normal"
sx={{ marginLeft: 1 }}
label="Minutes"
type="number"
onChange={updateDuration("m")}
inputProps={{
min: MIN_DURATION,
max: MAX_DURATION.m,
onInput: onNumberInput,
}}
value={duration.m}
autoComplete="off"
/>
</Box>
<TextField
margin="normal"
label="Expires"
value={`${(formerExpirey ?? expires).format("L")} ${(
formerExpirey ?? expires
).format("LT")}`}
/>
</Box>
);
}

View file

@ -1,4 +1,5 @@
import React, { useState, useContext } from "react";
import moment from "moment";
import StoreContext from "@qltr/store";
import Accordion from "@mui/material/Accordion";
@ -18,7 +19,7 @@ export default function SilencingBox(props) {
method: testMethod,
class: testClass,
id: silenceId,
silencedUntil,
expires,
} = silenceEntry;
const { state: store, updateStore } = useContext(StoreContext);
@ -61,11 +62,19 @@ export default function SilencingBox(props) {
<br />
{`Test Class: ${testClass}`}
<br />
{`Silenced Until: ${silencedUntil} Remaining Time: 2:50`}
{`Silenced Until: ${expires} Remaining Time: ${moment(
moment(expires).diff(moment())
).format("HH:mm")}`}
</Typography>
<Stack
sx={{ ml: "auto", display: { md: "none", lg: "none", xl: "none" } }}
sx={{
ml: "auto",
mb: "auto",
mt: "auto",
whiteSpace: "nowrap",
display: { md: "none", lg: "none", xl: "none" },
}}
>
<Actions />
</Stack>

View file

@ -9,7 +9,7 @@ import TextField from "@mui/material/TextField";
export default function Catalog() {
const { state: store, updateStore } = useContext(StoreContext);
const { state: jobState } = useContext(JobContext);
const { isLoading, data: tests } = useCatalogTests();
const { isLoading, data: rawTests } = useCatalogTests();
const handleSearchChange = (e) =>
updateStore({ catalogSearch: e.target.value });
@ -22,6 +22,8 @@ export default function Catalog() {
}, []);
const catalogWithJobs = () => {
const tests = rawTests.map((t) => ({ ...t, isPipeline: t.pipeline }));
for (var t of tests) delete t.pipeline;
for (var test of tests) {
if (test.isPipeline) {
const pipeline = jobState.pipelines.find((p) =>

View file

@ -9,6 +9,8 @@ import {
useJobNav,
} from "@qltr/util/JobTools";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Accordion from "@mui/material/Accordion";
import AccordionDetails from "@mui/material/AccordionDetails";
import AccordionSummary from "@mui/material/AccordionSummary";
@ -40,8 +42,9 @@ export default function CatalogBox(props) {
const { jobFactory } = useContext(JobContext);
const jobNav = useJobNav();
const [open, setOpen] = useState(false);
const toggleOpen = () => setOpen(!open);
const theme = useTheme();
const minifyActions = useMediaQuery(theme.breakpoints.down("sm"));
const navigateToJob = () => {
if (pipeline) return jobNav.toPipeline(pipeline.id);
@ -50,7 +53,10 @@ export default function CatalogBox(props) {
const runTest = () => {
if (isPipeline) return runPipelineTest();
const jobId = jobFactory({ testNames: [testName], isTriage: true });
const jobId = jobFactory({
testNames: [testName],
isTriage: store.triageFailing,
});
if (store.focusJob) jobNav.toJob(jobId);
};
@ -86,21 +92,6 @@ export default function CatalogBox(props) {
return useJobIconState(job);
}
function Actions() {
return (
<React.Fragment>
<IconButton
color="success"
aria-label="play"
component="span"
onClick={jobOnClick}
>
{jobIcon()}
</IconButton>
</React.Fragment>
);
}
return (
<Accordion
expanded={open}
@ -129,20 +120,18 @@ export default function CatalogBox(props) {
</Box>
<br />
</Typography>
<Stack
sx={{ ml: "auto", display: { md: "none", lg: "none", xl: "none" } }}
direction={minifyActions ? "column" : "row"}
sx={{ ml: "auto", mb: "auto", mt: "auto", whiteSpace: "nowrap" }}
>
<Actions />
</Stack>
<Stack
direction="row"
sx={{
ml: "auto",
display: { xs: "none", sm: "none", md: "flex", lg: "flex" },
}}
<IconButton
color="success"
aria-label="play"
component="span"
onClick={jobOnClick}
>
<Actions />
{jobIcon()}
</IconButton>
</Stack>
</AccordionSummary>
<AccordionDetails>

View file

@ -1,135 +1,103 @@
import { useState, useContext } from "react";
import { useCurrentlyFailing, useSilencedAlerts } from "@qltr/queries";
import StoreContext from "@qltr/store";
import JobContext from "@qltr/jobs";
import { useJobNav } from "@qltr/util/JobTools";
import SilenceDialog from "../alerting/SilenceDialog.jsx";
import SilenceDialog, { useSilenceDialog } from "../alerting/SilenceDialog.jsx";
import FailingBox from "./FailingBox.jsx";
import QuickSilence, { useQuickSilence } from "./QuickSilence.jsx";
import FailingRetry from "./FailingRetry.jsx";
import SpeedDial from "@mui/material/SpeedDial";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import ReplayIcon from "@mui/icons-material/Replay";
// MaterialUI
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
export default function Failing() {
const { state: jobState, retryAll } = useContext(JobContext);
const { state: store, silenceRequest } = useContext(StoreContext);
const { isLoading, data: failing } = useCurrentlyFailing();
const { isSilencedLoading, data: silencedAlerts } = useSilencedAlerts();
const jobNav = useJobNav();
const [silenceEntry, setSilenceEntry] = useState({ open: false });
const { state: jobState } = useContext(JobContext);
const { isLoading: failsLoading, data: failing } = useCurrentlyFailing();
const { isLoading: silencesLoading, data: silences } = useSilencedAlerts();
const [qsAnchor, qsTest, openQs, closeQs] = useQuickSilence();
const [sdOpen, sdToggle, silence, setSilence, sdClose] = useSilenceDialog();
const closeSilence = () => setSilenceEntry({ ...silenceEntry, open: false });
const handleSilenceClose = (silenceReq) => {
closeSilence();
if (!silenceReq) return;
silenceRequest(silenceReq);
const joinSilence = (result) => {
if (!silences) return;
const silence = silences.find((s) => s.name === result.name);
if (silence) result.silence = silence;
};
const editSilence = (silence) => () => {
setSilenceEntry({ ...silence, open: true });
};
const [retryAllOpen, setRetryAllOpen] = useState(false);
const retryAllClick = () => setRetryAllOpen(!retryAllOpen);
const handleClose = (confirmed) => () => {
retryAllClick();
if (!confirmed) return;
const jobId = retryAll(store.failing);
if (!store.focusJob) return;
jobNav.toJob(jobId);
};
const failingTestsWithJobs = () => {
if (isLoading) return [];
const silences = silencedAlerts ?? [];
for (var test of failing) {
const silence = silences.find(
(s) => s.name === test.name || s.class === test.class
const joinJob = (result) => {
if (result.isPipeline) return;
const job = jobState.jobs.find(
(j) => !j.isPipeline && j.builderCache.testNames.includes(result.name)
);
if (job) result.job = job;
};
if (silence) test.silencedUntil = silence;
if (test.isPipeline) {
const joinPipeline = (result) => {
if (!result.isPipeline) return;
const pipeline = jobState.pipelines.find((p) =>
p.selectedBranches.find((b) => b.name === test.name)
p.selectedBranches.find((b) => b.name === result.name)
);
if (!pipeline) continue;
if (!pipeline) return;
const pipelineJob = jobState.jobs.find(
(j) =>
j.isPipeline &&
j.pipelineId === pipeline.id &&
j.branchId === test.name
j.branchId === result.name
);
if (!pipelineJob) test.pipeline = pipeline;
test.job = pipelineJob;
continue;
if (!pipelineJob) result.pipeline = pipeline;
else result.job = pipelineJob;
};
const resultsCompiled = () => {
if (failsLoading) return [];
const compiled = [];
for (var r of failing) {
const rc = { ...r };
joinSilence(rc);
// Find associated job and skip pipeline
joinJob(rc);
// Find associated pipeline
joinPipeline(rc);
compiled.push(rc);
}
const job = jobState.jobs.find(
(j) => !j.isPipeline && j.builderCache.testNames.includes(test.name)
);
if (job) test.job = job;
}
return failing;
return compiled;
};
const alertClick = (result) => (e) => {
if (e) e.preventDefault();
if (e && e.type === "contextmenu" && result.silence)
return openQs(e, result);
const { name, class: className, method } = result;
const testInfo = { name, class: className, method };
const silenceInfo = result.silence ?? testInfo;
sdToggle();
setSilence(silenceInfo);
};
return (
<div className="failing">
{isLoading
? null
: failingTestsWithJobs().map((v, i) => (
<FailingBox key={i} failingTest={v} silenceClick={editSilence(v)} />
))}
<Dialog
open={retryAllOpen}
onClose={handleClose()}
sx={{ "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }}
maxWidth="xs"
>
<DialogTitle>Retry all failing tests?</DialogTitle>
<DialogContent>
<DialogContentText>
This will create x jobs and run y tests
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose()}>Cancel</Button>
<Button onClick={handleClose(true)} autoFocus>
Yes
</Button>
</DialogActions>
</Dialog>
<SilenceDialog
keepMounted
open={silenceEntry.open}
onClose={handleSilenceClose}
silence={silenceEntry}
open={sdOpen}
onClose={sdClose}
silence={silence}
/>
{(failing ?? []).length === 0 ? (
<QuickSilence
anchorEl={qsAnchor}
handleClose={closeQs}
test={qsTest}
editSilence={alertClick(qsTest)}
/>
<FailingRetry failing={failsLoading ? [] : failing} />
{!failsLoading && failing.length === 0 && (
<Box display="flex" alignItems="center" justifyContent="center">
<Typography variant="h4">No tests failing!</Typography>
</Box>
) : null}
{(failing ?? []).length === 0 ? null : (
<SpeedDial
ariaLabel="Retry All"
sx={{ position: "fixed", bottom: 16, right: 16 }}
icon={<ReplayIcon />}
onClick={retryAllClick}
open={false}
/>
)}
{!failsLoading &&
resultsCompiled().map((v, i) => (
<FailingBox key={i} failingTest={v} alertClick={alertClick(v)} />
))}
</div>
);
}

View file

@ -1,5 +1,5 @@
import React, { useState, useContext } from "react";
import { usePipelineMappings } from "@qltr/queries";
import { usePipelineMappings, useIgnoreResult } from "@qltr/queries";
import StoreContext from "@qltr/store";
import JobContext, { jobStatus } from "@qltr/jobs";
import {
@ -7,7 +7,8 @@ import {
usePipelineIconState,
useJobNav,
} from "@qltr/util/JobTools";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Accordion from "@mui/material/Accordion";
import AccordionDetails from "@mui/material/AccordionDetails";
import AccordionSummary from "@mui/material/AccordionSummary";
@ -42,30 +43,33 @@ import { asTree, asBranches, as1d } from "@qltr/util/pipelines.js";
const stopPropagation = (e) => e.stopPropagation() && e.preventDefault();
export default function FailingBox(props) {
const { failingTest, silenceClick } = props;
const { failingTest, alertClick } = props;
const {
class: testClass,
name: testName,
timestamp,
silencedUntil,
silence,
type,
dailyFails,
screenshot: screenshotUrl,
console: cs,
recentResults,
failedMessage,
message,
isPipeline,
job,
pipeline,
} = failingTest;
const ignoreResult = useIgnoreResult();
const runHistory = recentResults ? [...recentResults].reverse() : null;
const { data: pipelineMappings, isLoading } = usePipelineMappings();
const { jobFactory } = useContext(JobContext);
const { state: store, updateStore, removeFailure } = useContext(StoreContext);
const jobNav = useJobNav();
const theme = useTheme();
const minifyActions = useMediaQuery(theme.breakpoints.down("sm"));
const [open, setOpen] = useState(false);
const toggleOpen = () => setOpen(!open);
const [removeOpen, setRemoveOpen] = useState(false);
const removeClick = () => setRemoveOpen(!removeOpen);
@ -73,7 +77,7 @@ export default function FailingBox(props) {
stopPropagation(e);
setRemoveOpen(false);
if (!confirmed) return;
removeFailure(failingTest);
ignoreResult(failingTest);
};
function badgeColor() {
@ -91,7 +95,7 @@ export default function FailingBox(props) {
branches: asBranches(primaries),
tree: asTree(primaries),
selectedBranches: as1d(primaries),
isTriage: true,
isTriage: store.triageFailing,
};
const pipeline = jobFactory(builderCache);
if (store.focusJob) jobNav.toPipeline(pipeline.id);
@ -99,7 +103,10 @@ export default function FailingBox(props) {
const retryTest = () => {
if (isPipeline) return retryPipelineTest();
const jobId = jobFactory({ testNames: [testName], isTriage: true });
const jobId = jobFactory({
testNames: [testName],
isTriage: store.triageFailing,
});
if (store.focusJob) jobNav.toJob(jobId);
};
@ -120,39 +127,6 @@ export default function FailingBox(props) {
return useJobIconState(job);
}
function Actions() {
return (
<React.Fragment>
<a href={screenshotUrl}>
<IconButton aria-label="photo" component="span">
<PhotoCameraIcon />
</IconButton>
</a>
<IconButton aria-label="retry" component="span" onClick={jobOnClick}>
{jobIcon()}
</IconButton>
<IconButton
aria-label="silence"
component="span"
color={silencedUntil ? "primary" : "default"}
onClick={silenceClick}
>
<NotificationsIcon />
</IconButton>
<IconButton
color="error"
aria-label="delete"
component="span"
onClick={removeClick}
>
<DeleteIcon />
</IconButton>
</React.Fragment>
);
}
return (
<Accordion
expanded={open}
@ -212,10 +186,21 @@ export default function FailingBox(props) {
<br />
<div>
<span className="recent-results">
{recentResults.map(
{runHistory &&
runHistory.map(
(v, i) =>
(v && <CheckIcon key={i} color="success" />) || (
<ClearIcon key={i} color="error" />
(!v.failed && (
<CheckIcon
key={i}
color="success"
titleAccess={v.timestamp}
/>
)) || (
<ClearIcon
key={i}
color="error"
titleAccess={v.timestamp}
/>
)
)}
</span>
@ -225,27 +210,41 @@ export default function FailingBox(props) {
<Stack
onClick={stopPropagation}
sx={{ ml: "auto", display: { md: "none", lg: "none", xl: "none" } }}
direction={minifyActions ? "column" : "row"}
sx={{ ml: "auto", mb: "auto", mt: "auto", whiteSpace: "nowrap" }}
>
<Actions />
</Stack>
<Stack
onClick={stopPropagation}
direction="row"
sx={{
ml: "auto",
mb: "auto",
mt: "auto",
whiteSpace: "nowrap",
display: { xs: "none", sm: "none", md: "block", lg: "block" },
}}
<a href={screenshotUrl}>
<IconButton aria-label="photo" component="span">
<PhotoCameraIcon />
</IconButton>
</a>
<IconButton aria-label="retry" component="span" onClick={jobOnClick}>
{jobIcon()}
</IconButton>
<IconButton
aria-label="silence"
component="span"
color={silence && silence.expires ? "primary" : "default"}
onClick={alertClick}
onContextMenu={alertClick}
>
<Actions />
<NotificationsIcon titleAccess={silence && silence.expires} />
</IconButton>
<IconButton
color="error"
aria-label="delete"
component="span"
onClick={removeClick}
>
<DeleteIcon />
</IconButton>
</Stack>
</AccordionSummary>
<AccordionDetails>
<Typography component={"span"} style={{ wordBreak: "break-word" }}>
{failedMessage}
{message}
</Typography>
</AccordionDetails>
</Accordion>

View file

@ -0,0 +1,64 @@
// React
import React, { useState, useContext } from "react";
import JobContext from "@qltr/jobs";
import StoreContext from "@qltr/store";
import { useJobNav } from "@qltr/util/JobTools";
// Components
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import SpeedDial from "@mui/material/SpeedDial";
// Icons
import ReplayIcon from "@mui/icons-material/Replay";
export default function FailingRetry(props) {
const { failing } = props;
const { state: jobState, retryAll } = useContext(JobContext);
const { state: store } = useContext(StoreContext);
const [open, setOpen] = useState(false);
const jobNav = useJobNav();
const toggleOpen = () => setOpen(!open);
const dialogClose = (confirmed) => () => {
toggleOpen();
if (!confirmed) return;
const jobId = retryAll(failing);
if (!store.focusJob) return;
jobNav.toJob(jobId);
};
if (!failing || failing.length === 0) return;
return (
<React.Fragment>
<Dialog
open={open}
onClose={dialogClose()}
sx={{ "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }}
maxWidth="xs"
>
<DialogTitle>Retry all failing tests?</DialogTitle>
<DialogContent>
<DialogContentText>
This will create x jobs and run y tests
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={dialogClose()}>Cancel</Button>
<Button onClick={dialogClose(true)} autoFocus>
Yes
</Button>
</DialogActions>
</Dialog>
<SpeedDial
ariaLabel="Retry All"
sx={{ position: "fixed", bottom: 16, right: 16 }}
icon={<ReplayIcon />}
onClick={toggleOpen}
open={false}
/>
</React.Fragment>
);
}

View file

@ -0,0 +1,58 @@
// React
import { useState, useContext } from "react";
import { useUpsertAlert } from "@qltr/queries";
// Components
import Popover from "@mui/material/Popover";
import IconButton from "@mui/material/IconButton";
//Icons
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
export function useQuickSilence() {
const [anchorEl, setAnchorEl] = useState(null);
const [test, setTest] = useState(null);
const openMenuAt = (e, t) => {
e.preventDefault();
setTest(t);
setAnchorEl(e.currentTarget);
};
const closeMenu = () => setAnchorEl(null);
return [anchorEl, test, openMenuAt, closeMenu];
}
export default function QuickSilence(props) {
const { anchorEl, test, handleClose, editSilence } = props;
const upsertAlert = useUpsertAlert();
const open = Boolean(anchorEl);
async function deleteClick() {
await upsertAlert({ id: test.silence.id, expires: null });
handleClose();
}
async function editClick() {
editSilence();
handleClose();
}
return (
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
>
<IconButton color="primary" onClick={editClick}>
<EditIcon />
</IconButton>
<IconButton color="error" onClick={deleteClick}>
<DeleteIcon />
</IconButton>
</Popover>
);
}

View file

@ -1,4 +1,5 @@
import React, { useContext } from "react";
import { useNavigate } from "react-router-dom";
import JobContext, { jobStatus } from "@qltr/jobs";
import {
selectedPipelineBranches,
@ -37,6 +38,7 @@ function JobPipelineDisplay(props) {
} = useContext(JobContext);
const jobNav = useJobNav();
const nav = useNavigate();
const [anchorEl, setAnchorEl] = React.useState(null);
const open = Boolean(anchorEl);
@ -92,7 +94,7 @@ function JobPipelineDisplay(props) {
<Toolbar disableGutters />
<Box sx={{ flexGrow: 1, margin: "0 10px" }}>
<Toolbar disableGutters>
<IconButton onClick={jobNav.toJobs}>
<IconButton onClick={() => nav(-1)}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6" sx={{ ml: "auto", mr: "auto" }}>

View file

@ -1,5 +1,6 @@
import React, { useContext, useState, useEffect } from "react";
import { useJobNav } from "@qltr/util/JobTools";
import { useNavigate } from "react-router-dom";
import JobContext, { jobStatus } from "@qltr/jobs";
import StoreContext from "@qltr/store";
import Box from "@mui/material/Box";
@ -27,6 +28,7 @@ export default function JobView(props) {
const { jobFactory, jobCancel, jobDestroy } = useContext(JobContext);
const { state: store } = useContext(StoreContext);
const jobNav = useJobNav();
const nav = useNavigate();
const [anchorEl, setAnchorEl] = React.useState(null);
const open = Boolean(anchorEl);
const handleClick = (event) => {
@ -91,7 +93,7 @@ export default function JobView(props) {
<Toolbar disableGutters />
<Box sx={{ flexGrow: 1, margin: "0 10px" }}>
<Toolbar disableGutters>
<IconButton onClick={navigateToJobs}>
<IconButton onClick={() => nav(-1)}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6" component="span" sx={{ ml: "auto" }}>

View file

@ -52,7 +52,7 @@ export default function JobBuilder() {
const handleClose = (confirmed) => () => {
setJobDialogOpen(false);
if (!confirmed) return;
const jobId = jobFactory(cache);
const jobId = jobFactory({ ...cache, isTriage: store.triageFailing });
if (store.focusJob) jobNav.toJob(jobId);
};

View file

@ -115,6 +115,11 @@ export default function Settings(props) {
<Switch edge="end" checked={store.focusJob} />
</ListItem>
<ListItem button divider onClick={handleToggle("triageFailing")}>
<ListItemText primary="Triage Failing" />
<Switch edge="end" checked={store.triageFailing} />
</ListItem>
<ListItem button divider onClick={handleToggle("logAppDetails")}>
<ListItemText primary="Log App Details" />
<Switch edge="end" checked={store.logAppDetails} />

View file

@ -7,16 +7,18 @@ import axios from "axios";
const qltr = new Qualiteer();
await qltr.start();
const url = "https://Qualiteer.elijahparker3.repl.co";
const url = process.env.QUALITEER_EXECUTOR_URL;
const testsUrl = "/api/catalog/tests";
const mappingsUrl = "/api/catalog/pipeline-mappings";
const resultsUrl = "/api/results/failing";
const get = (...args) => axios.get(`${url}/${args[0]}`, args[1]);
const get = (...args) => axios.get(`${url}${args[0]}`, args[1]);
var res = await get(resultsUrl);
const mappings = await get(resultsUrl, {}).catch((e) => console.log(e));
console.log(mappings.data);
/*var res = await get(resultsUrl);
console.log(res.data);
res = await get(resultsUrl, { headers: { count: true } });
console.log(res.data);
console.log(res.data);*/

View file

@ -1,4 +1,4 @@
export default function failingTest() {
console.log("This came from a failing test!");
return { status: 1 };
return { status: 1, message: "This test always fails :(" };
}

View file

@ -1,4 +1,5 @@
import axios from "axios";
import Rabbiteer from "rabbiteer";
import primary from "./primary.js";
import secondary1 from "./secondary1.js";
import secondary2 from "./secondary2.js";
@ -13,11 +14,15 @@ import failing from "./failing.js";
// Constants
const liveUpdateDelay = 100;
const endLiveCount = 20;
const reportingUrl = `${process.env.QUALITEER_URL}/api/dev/rabbit/TestResults`;
const endLiveCount = 200;
const reportingUrl = `${process.env.QUALITEER_EXECUTOR_URL}/api/dev/rabbit/TestResults`;
// Pull args
const args = process.argv.slice(2);
const test = (args.find((v) => v.includes("test=")) ?? "").replace("test=", "");
const isTriage = (args.find((v) => v.includes("isTriage=")) ?? "").replace(
"isTriage=",
""
);
var pipeline = (args.find((v) => v.includes("pipeline=")) ?? "").replace(
"pipeline=",
""
@ -58,14 +63,26 @@ const runTests = () => {
// Run
liveIndicator();
setTimeout(() => {
setTimeout(async () => {
const status = runTests();
if (pipeline) pipeline.testData = status.pipelineData;
const testResult = {
...status,
failed: status.status !== 0,
name: test,
pipeline,
triage: isTriage === "true",
};
/*const rbt = new Rabbiteer(null, [], {
autoRabbit: { user: "guest", pass: "guest" },
});
await rbt.connect();
const data = JSON.stringify(testResult);
await rbt.publishMessageWithTimeout("TestResults", data);
rbt.disconnect();
if (status.status === 1) process.exit(1);*/
axios
.post(reportingUrl, { testResult })
.catch((e) => {

View file

@ -7,7 +7,7 @@ import { Initiator, Executor } from "qualiteer/clients";
const qltr = new Qualiteer();
await qltr.start();
const url = process.env.QUALITEER_URL;
const url = process.env.QUALITEER_EXECUTOR_URL;
// Create an initiator and make a job request
const primary = new Initiator(url);

View file

@ -2,20 +2,21 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
const { QUALITEER_VITE_BACKEND_URL, QUALITEER_VITE_DEV_PORT } = process.env;
const backendUrl = QUALITEER_VITE_BACKEND_URL ?? "http://localhost:52000";
const vitePort = QUALITEER_VITE_DEV_PORT ?? 52025;
export default () => {
return defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
hmr: {
port: 443,
},
port: vitePort,
proxy: {
"/api": "http://localhost:52000",
"/socket.io": {
target: "ws://localhost:52000",
ws: true,
"/api": backendUrl,
"/socket.io": backendUrl,
},
hmr: {
protocol: process.env.QUALITEER_VITE_DEV_PROTOCOL,
},
},
build: {
@ -26,10 +27,11 @@ export default () => {
alias: {
"@qltr/util": path.resolve("./src/util/"),
"@qltr/queries": path.resolve("./src/util/queries"),
"@qltr/joins": path.resolve(`./src/util/Joins.jsx`),
"@qltr/jobs": path.resolve("./src/ctx/JobContext.jsx"),
"@qltr/store": path.resolve("./src/ctx/StoreContext.jsx"),
"@qltr/initiator": path.resolve("./lib/sockets/clients/Initiator.js"),
"@qltr/mocks": path.resolve("./lib/database/mocks/")
"@qltr/mocks": path.resolve("./lib/database/mocks/"),
},
},
});