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/ build/
jobs/* jobs/*
qltr-executor 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,16 +1,18 @@
FROM node:16 FROM node:18
WORKDIR /dunemask/net/qualiteer WORKDIR /dunemask/net/qualiteer
# Copy dependencies # Copy dependencies
COPY package.json . COPY package.json .
COPY package-lock.json . COPY package-lock.json .
RUN npm i RUN npm i
# Copy react build resources over # Copy react build resources over
COPY public public COPY public public
COPY dist dist
COPY src src COPY src src
COPY lib lib COPY lib lib
COPY index.html . 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 over
COPY bin bin COPY bin bin
CMD ["npm","start"] CMD ["npm","start"]

View file

@ -7,20 +7,20 @@
- [x] Initial Skeleton - [x] Initial Skeleton
- [x] Frontend Drafts - [x] Frontend Drafts
- [x] Frontend Core - [x] Frontend Core
- [ ] Frontend Pages - [x] Frontend Pages
## v0.0.2 ## v0.0.2
- [ ] Database Queries (Req PG) - [x] Database Queries (Req PG)
- [ ] Database Tables (Req PG) - [x] Database Tables (Req PG)
- [ ] Backend Routes (Req Database) - [x] Backend Routes (Req Database)
- [ ] Crons - [ ] Crons
## v0.0.3 ## v0.0.3
- [ ] Rabbitmq Consumers (Req Database) - [x] Rabbitmq Consumers (Req Database)
- [ ] Alerting - [x] Alerting
- [ ] Silencing - [x] Silencing
## v0.0.4 ## 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(); const qltr = new Qualiteer();
await qltr.start(); await qltr.start();
process.env.INTERNAL_EXECUTOR = "true";

File diff suppressed because one or more lines are too long

View file

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

View file

@ -6,13 +6,14 @@ import { INFO, OK, logInfo } from "../util/logging.js";
// Import Core Modules // Import Core Modules
import buildRoutes from "../routes/router.js"; 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 injectSockets from "../sockets/socket-server.js";
import JobManager from "../jobs/JobManager.js"; import JobManager from "../jobs/JobManager.js";
import buildRabbiteer from "../rabbit/rabbit-workers.js"; import buildRabbiteer from "../rabbit/rabbit-workers.js";
// Constants // Constants
const title = "QLTR"; const title = "QLTR";
const rabbiteerEnabled = process.env.QUALITEER_RABBITEER_ENABLED !== "false";
const port = process.env.QUALITEER_DEV_PORT ?? 52000; const port = process.env.QUALITEER_DEV_PORT ?? 52000;
// Class // Class
@ -27,7 +28,7 @@ export default class Qualiteer {
logInfo(fig.textSync(title, "Cyberlarge")); logInfo(fig.textSync(title, "Cyberlarge"));
INFO("INIT", "Initializing..."); INFO("INIT", "Initializing...");
this.app = express(); this.app = express();
this.pg = buildPostgres(); this.pg = pg;
this.server = http.createServer(this.app); this.server = http.createServer(this.app);
this.sockets = injectSockets(this.server, this.jobs); this.sockets = injectSockets(this.server, this.jobs);
this.routes = buildRoutes(this.pg, this.sockets); this.routes = buildRoutes(this.pg, this.sockets);
@ -37,7 +38,8 @@ export default class Qualiteer {
async _connect() { async _connect() {
await this.pg.connect(); await this.pg.connect();
// await this.rabbiteer.connect(); if (!rabbiteerEnabled) return;
await this.rabbiteer.connect();
} }
start() { 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 = []; const conditionals = [];
for (var col of cols) { for (var col of cols) {
entry[col] = buildPostgresValue(entry[col]); 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 ")}`; return `DELETE FROM ${table} x WHERE ${conditionals.join(" AND ")}`;
}; };

View file

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

View file

@ -1,18 +1,56 @@
import pg from "../postgres.js"; import pg from "../postgres.js";
import { silencedMock } from "../mocks/alerting-mock.js"; import { silencedMock } from "../mocks/alerting-mock.js";
import moment from "moment";
// Imports // Imports
import { import {
insertQuery, insertQuery,
selectWhereAnyQuery, selectWhereAnyQuery,
updateWhereAnyQuery, updateWhereAnyQuery,
deleteQuery,
} from "../pg-query.js"; } from "../pg-query.js";
// Constants // Constants
const table = "silenced_tests"; const table = "alerting";
const PG_DISABLED = process.env.POSTGRES_DISABLED; 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 // Queries
export const getSilencedTests = async () => { export const getSilencedTests = async () => {
if (PG_DISABLED) return silencedMock(); if (PG_DISABLED) return silencedMock();
const query = `SELECT * from ${table}`; 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 { import {
insertQuery, insertQuery,
selectWhereAnyQuery, selectWhereAnyQuery,
updateWhereAnyQuery, onConflictUpdate,
} from "../pg-query.js"; } from "../pg-query.js";
import getFilteredTags from "../tags.js";
import getDelay from "../delays.js";
// Constants // Constants
const table = "tests"; const table = "catalog";
const PG_DISABLED = process.env.POSTGRES_DISABLED; const PG_DISABLED = process.env.POSTGRES_DISABLED;
import { testsMock, mappingsMock } from "../mocks/catalog-mock.js"; import { testsMock, mappingsMock } from "../mocks/catalog-mock.js";
// Queries // Queries
@ -19,8 +22,30 @@ export const getTests = async () => {
export const getPipelineMappings = async () => { export const getPipelineMappings = async () => {
if (PG_DISABLED) return mappingsMock(); if (PG_DISABLED) return mappingsMock();
const query = `SELECT * from ${table}`; const query = `SELECT * from ${table} WHERE pipeline`;
return pg.query(query); 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 () => { export const getProjects = async () => {
@ -28,3 +53,47 @@ export const getProjects = async () => {
const tests = testsMock(); 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, updateWhereAnyQuery,
} from "../pg-query.js"; } from "../pg-query.js";
// Constants // Constants
const table = "test_results"; const table = "results";
const recentResultsMax = 5;
const PG_DISABLED = process.env.POSTGRES_DISABLED; const PG_DISABLED = process.env.POSTGRES_DISABLED;
// Queries // Queries
export const insertTestResult = (testResult) => { export const insertTestResult = (testResult) => {
const { const {
test_name, name,
test_class, class: className,
test_method, method,
test_path, env,
test_type, timestamp,
test_timestamp, triage,
test_retry,
origin,
failed, failed,
failed_message, message,
screenshot_url, screenshot,
expected_screenshot_url, console: cs,
weblog_url,
} = testResult; } = testResult;
var query = insertQuery(table, { var query = insertQuery(table, {
test_name, name,
test_class, class: className,
test_method, method,
test_path, env,
test_type, timestamp,
test_timestamp, triage,
test_retry,
origin,
failed, failed,
failed_message, message,
screenshot_url, screenshot,
expected_screenshot_url, console: cs,
weblog_url,
}); });
query += "\n RETURNING *"; query += "\n RETURNING *";
@ -51,19 +46,48 @@ export const insertTestResult = (testResult) => {
export const getCurrentlyFailing = async () => { export const getCurrentlyFailing = async () => {
if (PG_DISABLED) return failingMock(); if (PG_DISABLED) return failingMock();
/**/ /* This can probably be changed into a super query, but perhaps faster/smaller */
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; 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`;
return pg.query(query); 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*/ const [currentlyFailing, applicableFails, failHistory] = await Promise.all([
failingQuery,
/* 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 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) => { export const ignoreResult = async ({ id }) => {
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; const query = updateWhereAnyQuery(table, { failed: false }, { id });
;`;
return pg.query(query); 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) { pushLog(jobId, log) {
const job = this.getJobById(jobId); const job = this.getJobById(jobId);
if (!job) return;
if (log instanceof Array) job.log.push(...log); if (log instanceof Array) job.log.push(...log);
else job.log.push(log); else job.log.push(log);
} }

View file

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

View file

@ -5,22 +5,13 @@
"name": "qltr-job-test-suite-1" "name": "qltr-job-test-suite-1"
}, },
"spec": { "spec": {
"ttlSecondsAfterFinished": 2,
"template": { "template": {
"spec": { "spec": {
"containers": [ "containers": [
{ {
"resources": {
"requests": {
"memory": "64MI",
"cpu": "250m"
},
"limits": {
"memory": "128MI",
"cpu": "500m"
}
},
"name": "qltr-job-test-suite-1", "name": "qltr-job-test-suite-1",
"image": "node", "image": "node:latest",
"imagePullPolicy": "Always", "imagePullPolicy": "Always",
"command": ["node", "--version"] "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"; 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) { export default async function createJob(jobRequest) {
const job = jobBuilder(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); applyFile(filePath);
deleteFile(filePath); deleteFile(filePath);*/
} }

View file

@ -2,16 +2,21 @@ import Rabbiteer from "rabbiteer";
import buildWorkers from "./workers/index.js"; import buildWorkers from "./workers/index.js";
// Pull Environment Variables // 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 // Rabbit Config
const rabbitConfig = { const rabbitConfig = {
host: host ?? "localhost", protocol: "amqp:",
user: user ?? "rabbit", host: `amqp://${host}` ?? "localhost",
pass: pass ?? "rabbit", user: user ?? "guest",
pass: pass ?? "guest",
}; };
const buildRabbiteer = (skio) => const buildRabbiteer = (pg, skio) =>
new Rabbiteer(null, buildWorkers(skio), { autoRabbit: rabbitConfig }); new Rabbiteer(null, buildWorkers(skio), { autoRabbit: rabbitConfig });
export default buildRabbiteer; export default buildRabbiteer;

View file

@ -1,5 +1,7 @@
// Imports // Imports
import { Worker } from "rabbiteer"; import { Worker } from "rabbiteer";
import { VERB } from "../../util/logging.js";
import { insertTestResult } from "../../database/queries/results.js";
import evt from "../../sockets/events.js"; import evt from "../../sockets/events.js";
// Class // Class
export default class TestResultsWorker extends Worker { export default class TestResultsWorker extends Worker {
@ -23,9 +25,9 @@ export default class TestResultsWorker extends Worker {
consoleLogUrl: https://consolelog” consoleLogUrl: https://consolelog”
} }
*/ */
onMessage(testResult) { async onMessage(testResult) {
const { pipeline } = testResult; const { pipeline } = testResult;
await this.handleReporting(testResult);
// Alter to start next test // Alter to start next test
// TODO the delay should be autopopulated either by the suite, or filled in by the server // TODO the delay should be autopopulated either by the suite, or filled in by the server
if (pipeline) return this.pipelineTrigger(pipeline); if (pipeline) return this.pipelineTrigger(pipeline);
@ -35,4 +37,9 @@ export default class TestResultsWorker extends Worker {
const { dashboardSocketId: dsi } = pipeline; const { dashboardSocketId: dsi } = pipeline;
this.skio.to(dsi).emit(evt.PPL_TRG, 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 { 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(); const router = Router();
// Apply Middlewares // Apply Middlewares
@ -12,7 +16,26 @@ router.get("/silenced", (req, res) => {
// Post Routes // Post Routes
router.post("/silence", (req, res) => { 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; export default router;

View file

@ -1,5 +1,9 @@
import { Router, json as jsonMiddleware } from "express"; 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 router = Router();
const maxSize = 1024 * 1024 * 100; // 100MB const maxSize = 1024 * 1024 * 100; // 100MB
@ -18,8 +22,10 @@ router.get("/pipeline-mappings", (req, res) => {
// Post Routes // Post Routes
router.post("/update", (req, res) => { router.post("/update", (req, res) => {
// Update All Tests if (!req.body) return res.status(400).send("Body required!");
res.sendStatus(200); 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; export default router;

View file

@ -1,5 +1,8 @@
import { Router, json as jsonMiddleware } from "express"; 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(); const router = Router();
// Apply Middlewares // Apply Middlewares
@ -15,4 +18,10 @@ router.post("/history", (req, res) => {
res.send([]); 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; export default router;

View file

@ -2,6 +2,7 @@
import express from "express"; import express from "express";
// Routes // Routes
import vitals from "../routes/vitals-route.js";
import results from "../routes/results-route.js"; import results from "../routes/results-route.js";
import alerting from "../routes/alerting-route.js"; import alerting from "../routes/alerting-route.js";
import react from "../routes/react-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) { export default function buildRoutes(pg, skio) {
const router = express.Router(); const router = express.Router();
// Special Routes // Special Routes
router.use(vitals);
router.all("/", (req, res) => res.redirect("/qualiteer")); router.all("/", (req, res) => res.redirect("/qualiteer"));
if (process.env.USE_DEV_ROUTER === "true") if (process.env.USE_DEV_ROUTER === "true")
router.use("/api/dev", buildDevRoute(pg, skio)); 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 { export default class Executor {
constructor(config, payload) { 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.jobId = config.jobId(payload) ?? process.env.QUALITEER_JOB_ID;
this.command = config.command(payload) ?? process.env.QUALITEER_COMMAND; this.command = config.command(payload) ?? process.env.QUALITEER_COMMAND;
this.mode = modes.EXEC; this.mode = modes.EXEC;

5655
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,18 +14,18 @@
"qualiteer": "./dist/app.js" "qualiteer": "./dist/app.js"
}, },
"scripts": { "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:executor": "node lib/jobs/executor/executor-bundler.js",
"build:react": "vite build", "build:react": "vite build",
"start": "node dist/app.js", "start": "node dist/app.js",
"start:dev": "nodemon dist/app.js", "dev:server": "nodemon dist/app.js",
"start:dev:replit": "npm run start:react:replit & (sleep 30 && npm run start:dev)", "dev:react": "vite",
"start:react": "vite preview", "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",
"start:react:replit": "vite --host",
"test": "node tests/index.js", "test": "node tests/index.js",
"test:api": "node tests/api.js", "test:api": "node tests/api.js",
"test:dev": "nodemon tests/index.js", "test:dev": "nodemon tests/index.js",
"lint": "prettier -w lib/ src/" "lint": "prettier -w lib/ src/",
"seed": "node lib/database/seed.js"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@ -40,44 +40,45 @@
] ]
}, },
"dependencies": { "dependencies": {
"amqplib": "^0.8.0", "@kubernetes/client-node": "^0.17.0",
"amqplib": "^0.10.3",
"chalk": "^5.0.1", "chalk": "^5.0.1",
"dotenv": "^16.0.0", "dotenv": "^16.0.2",
"express": "^4.17.2", "express": "^4.18.1",
"figlet": "^1.5.2", "figlet": "^1.5.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.3", "moment": "^2.29.4",
"path": "^0.12.7", "path": "^0.12.7",
"pg-promise": "^10.11.1", "pg-promise": "^10.12.0",
"postgres-migrations": "^5.3.0", "postgres-migrations": "^5.3.0",
"socket.io": "^4.4.1", "socket.io": "^4.5.2",
"socket.io-client": "^4.4.1", "socket.io-client": "^4.5.2",
"uuid": "^8.3.2" "uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@emotion/react": "^11.9.0", "@emotion/react": "^11.10.4",
"@emotion/styled": "^11.8.1", "@emotion/styled": "^11.10.4",
"@mui/icons-material": "^5.6.2", "@mui/icons-material": "^5.10.3",
"@mui/material": "^5.6.4", "@mui/material": "^5.10.4",
"@rollup/plugin-commonjs": "^22.0.0", "@rollup/plugin-commonjs": "^22.0.2",
"@rollup/plugin-node-resolve": "^13.3.0", "@rollup/plugin-node-resolve": "^14.0.0",
"@rollup/plugin-replace": "^4.0.0", "@rollup/plugin-replace": "^4.0.0",
"@tanstack/react-query": "^4.0.0", "@tanstack/react-query": "^4.2.3",
"@vitejs/plugin-react": "1.1.1", "@vitejs/plugin-react": "2.1.0",
"axios": "^0.27.2", "axios": "^0.27.2",
"caxa": "^2.1.0", "caxa": "^2.1.0",
"nodemon": "^2.0.15", "concurrently": "^7.3.0",
"nodemon": "^2.0.19",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"react": "^18.1.0", "react": "^18.2.0",
"react-dom": "^18.1.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"readline-sync": "^1.4.10", "readline-sync": "^1.4.10",
"rollup": "^2.72.0", "rollup": "^2.79.0",
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"vite": "2.7.0" "vite": "3.1.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"rabbiteer": "gitlab:Dunemask/rabbiteer" "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", PIPELINE: "p",
}; };
const url = "https://qualiteer.elijahparker3.repl.co/"; const url = "/";
const initialState = { const initialState = {
jobs: [], jobs: [],
@ -134,7 +134,7 @@ export const JobProvider = ({ children }) => {
request, request,
onLog, onLog,
onClose, onClose,
null, () => {},
onPipelineTrigger onPipelineTrigger
); );
started.then(() => jobUpdate({ status: jobStatus.ACTIVE }, jobId)); started.then(() => jobUpdate({ status: jobStatus.ACTIVE }, jobId));
@ -146,6 +146,7 @@ export const JobProvider = ({ children }) => {
const pipelineReq = { const pipelineReq = {
image: "node", image: "node",
pipeline: { __test, triggers: { ...tree[__test] } }, pipeline: { __test, triggers: { ...tree[__test] } },
isTriage: builderCache.triageFailing,
}; };
const id = `pij${Date.now()}`; const id = `pij${Date.now()}`;
const pipeline = { id, branches, pendingTriggers: [], selectedBranches }; const pipeline = { id, branches, pendingTriggers: [], selectedBranches };
@ -232,6 +233,7 @@ export const JobProvider = ({ children }) => {
image: "node", image: "node",
type: "single", type: "single",
name: jobId, name: jobId,
isTriage: builderCache.isTriage,
}; };
jobCreate(job); jobCreate(job);
@ -250,7 +252,7 @@ export const JobProvider = ({ children }) => {
jobUpdate({ ...job }, jobId); jobUpdate({ ...job }, jobId);
}; };
const started = i.newJob(request, onLog, onClose); const started = i.newJob(request, onLog, onClose, () => {});
started.then(() => jobUpdate({ status: jobStatus.ACTIVE }, jobId)); started.then(() => jobUpdate({ status: jobStatus.ACTIVE }, jobId));
return jobId; return jobId;

View file

@ -5,7 +5,7 @@ const ACTIONS = {
UPDATE: "u", UPDATE: "u",
}; };
const localStorage = { setItem: () => {}, getItem: () => {} }; // const localStorage = { setItem: () => {}, getItem: () => {} };
const localSettings = localStorage.getItem("settings"); const localSettings = localStorage.getItem("settings");
const defaultSettings = { const defaultSettings = {
@ -14,6 +14,7 @@ const defaultSettings = {
logAppDetails: true, logAppDetails: true,
defaultRegion: "us", defaultRegion: "us",
defaultPage: "failing", defaultPage: "failing",
triageFailing: true,
}; };
const settings = localSettings ? JSON.parse(localSettings) : defaultSettings; const settings = localSettings ? JSON.parse(localSettings) : defaultSettings;
@ -58,7 +59,7 @@ export const StoreProvider = ({ children }) => {
name: silenceInfo.name ?? "*", name: silenceInfo.name ?? "*",
class: silenceInfo.class ?? "*", class: silenceInfo.class ?? "*",
method: silenceInfo.method ?? "*", method: silenceInfo.method ?? "*",
silencedUntil: silenceInfo.silencedUntil, expires: silenceInfo.expires,
}; };
console.log("Would upsert silence", req); 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 { silencedMock } from "@qltr/mocks/alerting-mock.js";
import { failingMock } from "@qltr/mocks/results-mock.js"; import { failingMock } from "@qltr/mocks/results-mock.js";
import { testsMock, mappingsMock } from "@qltr/mocks/catalog-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 }); const asMock = (data) => ({ data });
@ -31,3 +31,44 @@ export const useCurrentlyFailing = () =>
useMock useMock
? asMock(failingMock()) ? asMock(failingMock())
: useQuery(["failing"], fetchApi("/results/failing")); : 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>
<Typography variant="body1"> <Typography variant="body1">
Qualiteer was designed to solve the issue of "on call". A state of 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 what is broken in production! 🤯 Qualiteer gives users power to
resolve and reattempt failing tests, run a particular suite of tests, resolve and reattempt failing tests, run a particular suite of tests,
and mute pesky alerts reminding you the navbar's color changed... 🤦 and mute pesky alerts reminding you the navbar's color changed... 🤦

View file

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

View file

@ -1,6 +1,5 @@
import { useState, useContext, useEffect } from "react"; import { useState, useContext, useEffect } from "react";
import StoreContext from "@qltr/store"; import { useUpsertAlert } from "@qltr/queries";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
@ -11,25 +10,53 @@ import Dialog from "@mui/material/Dialog";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import Toolbar from "@mui/material/Toolbar"; 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) { export default function SilenceDialog(props) {
const { silence, open, onClose } = props; const { silence, open, onClose } = props;
const [duration, setDuration] = useState({ h: 0, m: 0 });
const theme = useTheme(); const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const [silenceEntry, setSilenceEntry] = useState(silence); 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(() => { useEffect(() => {
setSilenceEntry(silence); setSilenceEntry(silence);
setDuration({ h: 0, m: 0 });
}, [silence, open]); }, [silence, open]);
const { state: store, updateStore } = useContext(StoreContext);
const handleCancel = () => onClose(); const handleCancel = () => onClose();
const handleOk = () => onClose(silenceEntry); const handleOk = () => onClose(silenceEntry);
const updateSilence = (silenceType) => (e) => { const updateSilence = (silenceType) => (e) => {
silenceEntry[silenceType] = e.target.value; const s = { ...silenceEntry };
setSilenceEntry({ ...silenceEntry }); s[silenceType] = e.target.value;
setSilenceEntry({ ...s, expires: duration });
};
const updateDuration = (dur) => {
setDuration(dur);
setSilenceEntry({ ...silenceEntry, expires: dur });
}; };
return ( return (
@ -37,7 +64,7 @@ export default function SilenceDialog(props) {
sx={ sx={
fullScreen fullScreen
? {} ? {}
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } } : { "& .MuiDialog-paper": { width: "80%", maxHeight: 525 } }
} }
maxWidth="xs" maxWidth="xs"
open={open} open={open}
@ -70,12 +97,19 @@ export default function SilenceDialog(props) {
onChange={updateSilence("class")} onChange={updateSilence("class")}
margin="normal" margin="normal"
/> />
<SilenceSelector
formerExpires={silence.expires}
duration={duration}
setDuration={updateDuration}
/>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button autoFocus onClick={handleCancel}> <Button autoFocus onClick={handleCancel}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleOk}>Ok</Button> <Button onClick={handleOk} disabled={!modified}>
Ok
</Button>
</DialogActions> </DialogActions>
</Dialog> </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 React, { useState, useContext } from "react";
import moment from "moment";
import StoreContext from "@qltr/store"; import StoreContext from "@qltr/store";
import Accordion from "@mui/material/Accordion"; import Accordion from "@mui/material/Accordion";
@ -18,7 +19,7 @@ export default function SilencingBox(props) {
method: testMethod, method: testMethod,
class: testClass, class: testClass,
id: silenceId, id: silenceId,
silencedUntil, expires,
} = silenceEntry; } = silenceEntry;
const { state: store, updateStore } = useContext(StoreContext); const { state: store, updateStore } = useContext(StoreContext);
@ -61,11 +62,19 @@ export default function SilencingBox(props) {
<br /> <br />
{`Test Class: ${testClass}`} {`Test Class: ${testClass}`}
<br /> <br />
{`Silenced Until: ${silencedUntil} Remaining Time: 2:50`} {`Silenced Until: ${expires} Remaining Time: ${moment(
moment(expires).diff(moment())
).format("HH:mm")}`}
</Typography> </Typography>
<Stack <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 /> <Actions />
</Stack> </Stack>

View file

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

View file

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

View file

@ -1,135 +1,103 @@
import { useState, useContext } from "react"; import { useState, useContext } from "react";
import { useCurrentlyFailing, useSilencedAlerts } from "@qltr/queries"; import { useCurrentlyFailing, useSilencedAlerts } from "@qltr/queries";
import StoreContext from "@qltr/store";
import JobContext from "@qltr/jobs"; import JobContext from "@qltr/jobs";
import { useJobNav } from "@qltr/util/JobTools"; 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 FailingBox from "./FailingBox.jsx";
import QuickSilence, { useQuickSilence } from "./QuickSilence.jsx";
import FailingRetry from "./FailingRetry.jsx";
import SpeedDial from "@mui/material/SpeedDial"; // MaterialUI
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";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
export default function Failing() { export default function Failing() {
const { state: jobState, retryAll } = useContext(JobContext); const { state: jobState } = useContext(JobContext);
const { state: store, silenceRequest } = useContext(StoreContext); const { isLoading: failsLoading, data: failing } = useCurrentlyFailing();
const { isLoading, data: failing } = useCurrentlyFailing(); const { isLoading: silencesLoading, data: silences } = useSilencedAlerts();
const { isSilencedLoading, data: silencedAlerts } = useSilencedAlerts(); const [qsAnchor, qsTest, openQs, closeQs] = useQuickSilence();
const jobNav = useJobNav(); const [sdOpen, sdToggle, silence, setSilence, sdClose] = useSilenceDialog();
const [silenceEntry, setSilenceEntry] = useState({ open: false });
const closeSilence = () => setSilenceEntry({ ...silenceEntry, open: false }); const joinSilence = (result) => {
if (!silences) return;
const handleSilenceClose = (silenceReq) => { const silence = silences.find((s) => s.name === result.name);
closeSilence(); if (silence) result.silence = silence;
if (!silenceReq) return;
silenceRequest(silenceReq);
}; };
const editSilence = (silence) => () => { const joinJob = (result) => {
setSilenceEntry({ ...silence, open: true }); if (result.isPipeline) return;
const job = jobState.jobs.find(
(j) => !j.isPipeline && j.builderCache.testNames.includes(result.name)
);
if (job) result.job = job;
}; };
const [retryAllOpen, setRetryAllOpen] = useState(false); const joinPipeline = (result) => {
const retryAllClick = () => setRetryAllOpen(!retryAllOpen); if (!result.isPipeline) return;
const pipeline = jobState.pipelines.find((p) =>
const handleClose = (confirmed) => () => { p.selectedBranches.find((b) => b.name === result.name)
retryAllClick(); );
if (!confirmed) return; if (!pipeline) return;
const jobId = retryAll(store.failing); const pipelineJob = jobState.jobs.find(
(j) =>
if (!store.focusJob) return; j.isPipeline &&
jobNav.toJob(jobId); j.pipelineId === pipeline.id &&
j.branchId === result.name
);
if (!pipelineJob) result.pipeline = pipeline;
else result.job = pipelineJob;
}; };
const failingTestsWithJobs = () => { const resultsCompiled = () => {
if (isLoading) return []; if (failsLoading) return [];
const silences = silencedAlerts ?? []; const compiled = [];
for (var test of failing) { for (var r of failing) {
const silence = silences.find( const rc = { ...r };
(s) => s.name === test.name || s.class === test.class joinSilence(rc);
); // Find associated job and skip pipeline
joinJob(rc);
if (silence) test.silencedUntil = silence; // Find associated pipeline
if (test.isPipeline) { joinPipeline(rc);
const pipeline = jobState.pipelines.find((p) => compiled.push(rc);
p.selectedBranches.find((b) => b.name === test.name)
);
if (!pipeline) continue;
const pipelineJob = jobState.jobs.find(
(j) =>
j.isPipeline &&
j.pipelineId === pipeline.id &&
j.branchId === test.name
);
if (!pipelineJob) test.pipeline = pipeline;
test.job = pipelineJob;
continue;
}
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 ( return (
<div className="failing"> <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 <SilenceDialog
keepMounted keepMounted
open={silenceEntry.open} open={sdOpen}
onClose={handleSilenceClose} onClose={sdClose}
silence={silenceEntry} 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"> <Box display="flex" alignItems="center" justifyContent="center">
<Typography variant="h4">No tests failing!</Typography> <Typography variant="h4">No tests failing!</Typography>
</Box> </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> </div>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -7,16 +7,18 @@ import axios from "axios";
const qltr = new Qualiteer(); const qltr = new Qualiteer();
await qltr.start(); 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 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); console.log(res.data);
res = await get(resultsUrl, { headers: { count: true } }); 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() { export default function failingTest() {
console.log("This came from a failing test!"); 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 axios from "axios";
import Rabbiteer from "rabbiteer";
import primary from "./primary.js"; import primary from "./primary.js";
import secondary1 from "./secondary1.js"; import secondary1 from "./secondary1.js";
import secondary2 from "./secondary2.js"; import secondary2 from "./secondary2.js";
@ -13,11 +14,15 @@ import failing from "./failing.js";
// Constants // Constants
const liveUpdateDelay = 100; const liveUpdateDelay = 100;
const endLiveCount = 20; const endLiveCount = 200;
const reportingUrl = `${process.env.QUALITEER_URL}/api/dev/rabbit/TestResults`; const reportingUrl = `${process.env.QUALITEER_EXECUTOR_URL}/api/dev/rabbit/TestResults`;
// Pull args // Pull args
const args = process.argv.slice(2); const args = process.argv.slice(2);
const test = (args.find((v) => v.includes("test=")) ?? "").replace("test=", ""); 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( var pipeline = (args.find((v) => v.includes("pipeline=")) ?? "").replace(
"pipeline=", "pipeline=",
"" ""
@ -58,14 +63,26 @@ const runTests = () => {
// Run // Run
liveIndicator(); liveIndicator();
setTimeout(() => { setTimeout(async () => {
const status = runTests(); const status = runTests();
if (pipeline) pipeline.testData = status.pipelineData; if (pipeline) pipeline.testData = status.pipelineData;
const testResult = { const testResult = {
...status, ...status,
failed: status.status !== 0,
name: test, name: test,
pipeline, 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 axios
.post(reportingUrl, { testResult }) .post(reportingUrl, { testResult })
.catch((e) => { .catch((e) => {

View file

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

View file

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