feat: replace CLI script with web UI (Joe) for listing outdated applications
This commit is contained in:
parent
e4a01bb96c
commit
76a8909dc1
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
|
||||
.env.local
|
9
Makefile
Normal file
9
Makefile
Normal file
@ -0,0 +1,9 @@
|
||||
dev:
|
||||
@make tdd&
|
||||
bun --hot run joe.ts
|
||||
|
||||
test:
|
||||
bun test
|
||||
|
||||
tdd:
|
||||
bun test --watch
|
@ -1,219 +0,0 @@
|
||||
#!/usr/bin/env zx
|
||||
$.verbose = argv.v || argv.verbose;
|
||||
|
||||
if (argv.help) {
|
||||
console.log(`${chalk.white.bgBlue(
|
||||
"🐿️ captain-update.mjs - Check for updates of all deployed images on CapRover"
|
||||
)}
|
||||
|
||||
Usage: ${chalk.yellow(
|
||||
"CAPTAIN_DOMAIN=captain.my.example.com CAPTAIN_PASSWORD='This is super S3cure!' ./captain-update.mjs [options]"
|
||||
)}
|
||||
${chalk.grey(
|
||||
"CAPTAIN_DOMAIN must have the domain name of the CapRover instance"
|
||||
)}
|
||||
${chalk.grey(
|
||||
"CAPTAIN_PASSWORD must have the password of the CapRover instance"
|
||||
)}
|
||||
|
||||
Optional options:
|
||||
${chalk.yellow("--help")} Display this help
|
||||
${chalk.yellow("-v --verbose")} Verbose output
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const hasAllEnvVars =
|
||||
process.env.CAPTAIN_DOMAIN && process.env.CAPTAIN_PASSWORD;
|
||||
if (!hasAllEnvVars) {
|
||||
console.error(
|
||||
`Missing ${chalk.red("CAPTAIN_DOMAIN")} or ${chalk.red(
|
||||
"CAPTAIN_PASSWORD"
|
||||
)} environment variables. Use ${chalk.green("--help")} for help.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const API_URL = `https://${process.env.CAPTAIN_DOMAIN}/api/v2`;
|
||||
|
||||
const authResponse = await fetch(`${API_URL}/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-namespace": "captain",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
password: process.env.CAPTAIN_PASSWORD,
|
||||
}),
|
||||
}).then((res) => res.json());
|
||||
|
||||
const authToken = authResponse?.data?.token;
|
||||
if (!authToken) {
|
||||
console.error(`Failed to authenticate at ${API_URL}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fetchAuthenticatedApi = async (url, method = "GET", body = undefined) => {
|
||||
const response = await fetch(`${API_URL}${url}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-namespace": "captain",
|
||||
"x-captain-auth": authToken,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then((res) => res.json());
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const applicationsResponse = await fetchAuthenticatedApi(
|
||||
"/user/apps/appDefinitions"
|
||||
);
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
`Found ${applicationsResponse.data.appDefinitions.length} applications on ${applicationsResponse.data.rootDomain}`
|
||||
)
|
||||
);
|
||||
const appStates = await Promise.all(
|
||||
applicationsResponse.data.appDefinitions.map(async (app) => {
|
||||
const currentVersion = app.versions[app.deployedVersion];
|
||||
|
||||
const [imageName, currentTag] = currentVersion.deployedImageName.split(":");
|
||||
const imagePath = imageName.includes("/")
|
||||
? imageName
|
||||
: `library/${imageName}`;
|
||||
|
||||
let appState = {
|
||||
name: app.appName,
|
||||
lastDeployed: currentVersion.timeStamp,
|
||||
currentImage: {
|
||||
name: currentVersion.deployedImageName,
|
||||
dockerHubUrl: imageName.includes("/")
|
||||
? `https://hub.docker.com/r/${imageName}`
|
||||
: `https://hub.docker.com/_/${imageName}`,
|
||||
image: imageName,
|
||||
tag: currentTag,
|
||||
},
|
||||
};
|
||||
|
||||
const tagsResponse = await fetch(
|
||||
`https://hub.docker.com/v2/repositories/${imagePath}/tags?page_size=10&page=1&ordering=last_updated`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Impossible to get Docker tags for image",
|
||||
chalk.red(imagePath)
|
||||
);
|
||||
return {};
|
||||
});
|
||||
const tags = tagsResponse.results;
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
return appState;
|
||||
}
|
||||
|
||||
const latestTag = tags.find((tag) => tag.name === "latest");
|
||||
const latestTags = tags
|
||||
.filter((tag) => {
|
||||
if (!latestTag) {
|
||||
return true;
|
||||
}
|
||||
return tag.digest === latestTag.digest;
|
||||
})
|
||||
.map((tag) => tag.name);
|
||||
|
||||
const isLatest = latestTags.includes(currentTag);
|
||||
|
||||
appState.isLatest = latestTag && isLatest;
|
||||
appState.latestTags = latestTags;
|
||||
appState.recommendedVersion = `${imageName}:${latestTags.pop()}`;
|
||||
|
||||
return appState;
|
||||
})
|
||||
);
|
||||
|
||||
const appsUpToDate = appStates.filter((app) => app.isLatest);
|
||||
const appsUnknown = appStates.filter((app) => !app.recommendedVersion);
|
||||
const appsOutdated = appStates.filter(
|
||||
(app) => app.recommendedVersion && !app.isLatest
|
||||
);
|
||||
|
||||
console.log(
|
||||
"\n",
|
||||
chalk.green(`# Applications report (${new Date().toISOString()})`),
|
||||
"\n\n"
|
||||
);
|
||||
|
||||
console.log(chalk.green("## Applications up-to-date", "\n"));
|
||||
console.log(
|
||||
chalk.grey(
|
||||
"These applications seems up-to-date, congrats! Please check for unsupported software not updated recently, and improve this script if possible."
|
||||
),
|
||||
"\n"
|
||||
);
|
||||
appsUpToDate
|
||||
.sort((a, b) => new Date(a.lastDeployed) - new Date(b.lastDeployed))
|
||||
.forEach((app) => {
|
||||
console.log(
|
||||
"- [x]",
|
||||
chalk.blue(app.name),
|
||||
`: ${app.currentImage.name}`,
|
||||
"\n\t",
|
||||
"- current:",
|
||||
app.currentImage.name,
|
||||
"\n\t",
|
||||
"- last deployed:",
|
||||
app.lastDeployed
|
||||
);
|
||||
});
|
||||
|
||||
console.log("\n\n", chalk.green("## Applications unknown", "\n"));
|
||||
console.log(
|
||||
chalk.grey(
|
||||
"I couldn't automatically find the latest version for these apps. Please check manually, and improve this script if possible."
|
||||
),
|
||||
"\n"
|
||||
);
|
||||
appsUnknown.forEach((app) => {
|
||||
console.log(
|
||||
"- [ ]",
|
||||
chalk.blue(app.name),
|
||||
`: ${app.currentImage.name} -> TODO`,
|
||||
"\n\t",
|
||||
"- current:",
|
||||
app.currentImage.name,
|
||||
"\n\t",
|
||||
"- docker hub URL:",
|
||||
app.currentImage.dockerHubUrl
|
||||
);
|
||||
});
|
||||
|
||||
console.log("\n\n", chalk.green("## Applications outdated", "\n"));
|
||||
console.log(
|
||||
chalk.grey(
|
||||
"These applications seems outdated. Please try to update to their latest versions, and improve this script if possible."
|
||||
),
|
||||
"\n"
|
||||
);
|
||||
appsOutdated.forEach((app) => {
|
||||
console.log(
|
||||
"- [ ]",
|
||||
chalk.blue(app.name),
|
||||
`: ${app.currentImage.name} -> ${app.recommendedVersion}`,
|
||||
"\n\t",
|
||||
"- current:",
|
||||
app.currentImage.name,
|
||||
"\n\t",
|
||||
"- recommended:",
|
||||
app.recommendedVersion,
|
||||
"\n\t",
|
||||
"- docker hub URL:",
|
||||
app.currentImage.dockerHubUrl,
|
||||
"\n\t",
|
||||
"- tags:",
|
||||
app.latestTags.join(", ")
|
||||
);
|
||||
});
|
39
joe.ts
Normal file
39
joe.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import Caprover from "./services/Caprover";
|
||||
import applications from "./routes/applications";
|
||||
import applicationsUpdate from "./routes/applications.update";
|
||||
import Layout, { html } from "./ui/Layout";
|
||||
import DockerHub from "./services/DockerHub";
|
||||
|
||||
console.log("Hello Pierrot!");
|
||||
|
||||
const caprover = new Caprover(
|
||||
process.env.CAPTAIN_DOMAIN,
|
||||
process.env.CAPTAIN_PASSWORD
|
||||
);
|
||||
|
||||
const dockerHub = new DockerHub();
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 3000,
|
||||
fetch(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
switch (url.pathname) {
|
||||
case "/":
|
||||
return new Response(
|
||||
Layout(
|
||||
html`<h1>Hello World.</h1>
|
||||
<p>I'm Joe, your personal assistant.</p>`
|
||||
),
|
||||
{ headers: { "Content-Type": "text/html" } }
|
||||
);
|
||||
case "/applications":
|
||||
return applications(req, caprover);
|
||||
case "/applications/update":
|
||||
return applicationsUpdate(req, caprover, dockerHub);
|
||||
default:
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Server started at http://${server.hostname}:${server.port}`);
|
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.0.25"
|
||||
}
|
||||
}
|
91
routes/applications.ts
Normal file
91
routes/applications.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import Caprover, { Application } from "../services/Caprover";
|
||||
import Layout, { html } from "../ui/Layout";
|
||||
|
||||
const OLD_PERIOD_IN_DAYS = 60;
|
||||
|
||||
const ApplicationOverview = (application: Application) => {
|
||||
const deployedAt = application.lastDeployedAt.toLocaleDateString("fr-FR");
|
||||
return html`<tr>
|
||||
<td>${application.name}</td>
|
||||
<td>
|
||||
<details>
|
||||
<summary>
|
||||
<code>${application.imageName}</code>
|
||||
</summary>
|
||||
<pre style="user-select: all;">${application.toString()}</pre>
|
||||
</details>
|
||||
</td>
|
||||
<td>
|
||||
${application.isOlderThan(OLD_PERIOD_IN_DAYS)
|
||||
? `<mark>${deployedAt}</mark>`
|
||||
: deployedAt}
|
||||
</td>
|
||||
<td>
|
||||
${application.dockerImage
|
||||
? `<a href="${application.dockerImage.hubUrl}/tags">
|
||||
<img height="32" width="32" src="https://cdn.simpleicons.org/docker" alt="Docker hub"/>
|
||||
</a>`
|
||||
: ""}
|
||||
</td>
|
||||
</tr> `;
|
||||
};
|
||||
|
||||
type Sort = { field: string; order: "asc" | "desc" };
|
||||
|
||||
const Page = (applications: Application[], currentSort: Sort) => {
|
||||
const sortLink = (field: string, title: string) => {
|
||||
let url = `?sort=${field}`;
|
||||
let className = "";
|
||||
if (currentSort.field === field) {
|
||||
className = "current";
|
||||
title += currentSort.order === "asc" ? " ▲" : " ▼";
|
||||
url += `&order=${currentSort.order === "asc" ? "desc" : "asc"}`;
|
||||
}
|
||||
|
||||
return `<a href="${url}" class="${className}">${title}</a>`;
|
||||
};
|
||||
|
||||
return html`<div>
|
||||
<h1>Applications</h1>
|
||||
|
||||
<a href="/applications/update" class="button">Update applications now!</a>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${sortLink("name", "Name")}</th>
|
||||
<th>Deployment</th>
|
||||
<th>${sortLink("deployed", "Last deployed")}</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${applications.map((app) => ApplicationOverview(app)).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
export default async (req: Request, caprover: Caprover): Promise<Response> => {
|
||||
const applications = await caprover.getApps();
|
||||
|
||||
const sort: Sort = {
|
||||
field: new URL(req.url).searchParams.get("sort") ?? "name",
|
||||
order:
|
||||
new URL(req.url).searchParams.get("order") === "desc" ? "desc" : "asc",
|
||||
};
|
||||
if (sort.field === "name") {
|
||||
applications.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else if (sort.field === "deployed") {
|
||||
applications.sort(
|
||||
(a, b) => a.lastDeployedAt.getTime() - b.lastDeployedAt.getTime()
|
||||
);
|
||||
}
|
||||
if (sort.order === "desc") {
|
||||
applications.reverse();
|
||||
}
|
||||
|
||||
return new Response(Layout(Page(applications, sort)), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
};
|
82
routes/applications.update.ts
Normal file
82
routes/applications.update.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import Caprover, { Application } from "../services/Caprover";
|
||||
import DockerHub from "../services/DockerHub";
|
||||
import Layout, { html } from "../ui/Layout";
|
||||
|
||||
const UpdateForm = (application: Application, latestVersions: string[]) => {
|
||||
// sort by number of "dots" in the version to have the most specific version first
|
||||
latestVersions.sort((a, b) => {
|
||||
return b.split(".").length - a.split(".").length;
|
||||
});
|
||||
|
||||
return html`<form method="POST">
|
||||
<input type="hidden" name="appName" value="${application.name}" />
|
||||
<select name="version">
|
||||
${latestVersions.map((version) => {
|
||||
return html`<option value="${version}">${version}</option>`;
|
||||
})}
|
||||
</select>
|
||||
<button type="submit">Update</button>
|
||||
</form>`;
|
||||
};
|
||||
|
||||
const Page = (application: Application, latestVersions: string[]) => {
|
||||
return html`<div>
|
||||
<h1>Updating ${application.name}</h1>
|
||||
|
||||
<dl>
|
||||
<dt>Last deployment</dt>
|
||||
<dd>${application.lastDeployedAt.toLocaleString("fr-FR")}</dd>
|
||||
|
||||
<dt>Current version</dt>
|
||||
<dd>
|
||||
${application.dockerImage
|
||||
? `<img height="32" width="32" src="https://cdn.simpleicons.org/docker" alt="Docker hub"/> <a href="${application.dockerImage.hubUrl}/tags">
|
||||
${application.dockerImage.name}:${application.dockerImage.tag}
|
||||
</a>`
|
||||
: `<code>${application.imageName}</code>… check yourself!`}
|
||||
</dd>
|
||||
|
||||
<dt>Latest versions</dt>
|
||||
<dd>
|
||||
${latestVersions.length === 0
|
||||
? "No version found"
|
||||
: UpdateForm(application, latestVersions)}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
export default async (
|
||||
req: Request,
|
||||
caprover: Caprover,
|
||||
dockerHub: DockerHub
|
||||
): Promise<Response> => {
|
||||
if (req.method === "POST") {
|
||||
const body = await req.formData();
|
||||
console.log("TODO Implement application update", [...body.entries()]);
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
const applications = await caprover.getApps();
|
||||
|
||||
const appToUpdate = applications
|
||||
.filter((app) => {
|
||||
// can we help to update this app?
|
||||
return app.dockerImage;
|
||||
})
|
||||
.find((app) => app.isOlderThan(30));
|
||||
|
||||
if (!appToUpdate) {
|
||||
return new Response(Layout(html`<h1>No application to update 🎉</h1>`), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
|
||||
const latestVersions = await dockerHub.getLatestVersions(
|
||||
appToUpdate.dockerImage!.name
|
||||
);
|
||||
|
||||
return new Response(Layout(Page(appToUpdate, latestVersions)), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
};
|
106
services/Caprover.test.ts
Normal file
106
services/Caprover.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { Application } from "./Caprover";
|
||||
|
||||
describe("Caprover", () => {
|
||||
describe("Application", () => {
|
||||
const anAppDefinition = {
|
||||
hasPersistentData: false,
|
||||
description: "",
|
||||
instanceCount: 1,
|
||||
captainDefinitionRelativeFilePath: "./captain-definition",
|
||||
networks: ["captain-overlay-network"],
|
||||
envVars: [
|
||||
{
|
||||
key: "ADMINER_PLUGINS",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "ADMINER_DESIGN",
|
||||
value: "",
|
||||
},
|
||||
],
|
||||
volumes: [],
|
||||
ports: [],
|
||||
versions: [
|
||||
{
|
||||
version: 0,
|
||||
timeStamp: "2020-08-02T01:25:07.232Z",
|
||||
deployedImageName: "img-captain-adminer:0",
|
||||
gitHash: "",
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
timeStamp: "2021-03-19T10:04:54.823Z",
|
||||
deployedImageName: "adminer:4.8.0",
|
||||
gitHash: "",
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
timeStamp: "2021-12-04T10:24:48.757Z",
|
||||
deployedImageName: "adminer:4.8.1",
|
||||
gitHash: "",
|
||||
},
|
||||
],
|
||||
deployedVersion: 2,
|
||||
notExposeAsWebApp: false,
|
||||
customDomain: [],
|
||||
hasDefaultSubDomainSsl: true,
|
||||
forceSsl: true,
|
||||
websocketSupport: false,
|
||||
containerHttpPort: 8080,
|
||||
preDeployFunction: "",
|
||||
serviceUpdateOverride: "",
|
||||
appName: "adminer",
|
||||
isAppBuilding: false,
|
||||
};
|
||||
|
||||
it("should create an application from definition", () => {
|
||||
const app = Application.createFromDefinition(anAppDefinition);
|
||||
expect(app.name).toBe("adminer");
|
||||
});
|
||||
|
||||
describe("docker image", () => {
|
||||
it("should return the docker image name of the current version", () => {
|
||||
const app = Application.createFromDefinition(anAppDefinition);
|
||||
expect(app.dockerImage).toEqual({
|
||||
name: "adminer",
|
||||
tag: "4.8.1",
|
||||
hubUrl: "https://hub.docker.com/_/adminer",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse names with organization namespace", () => {
|
||||
const app = Application.createFromDefinition({
|
||||
...anAppDefinition,
|
||||
deployedVersion: 0,
|
||||
versions: [
|
||||
{
|
||||
...anAppDefinition.versions[0],
|
||||
deployedImageName: "vaultwarden/server:1.30.0-alpine",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(app.dockerImage).toEqual({
|
||||
name: "vaultwarden/server",
|
||||
tag: "1.30.0-alpine",
|
||||
hubUrl: "https://hub.docker.com/r/vaultwarden/server",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not parse custom built images", () => {
|
||||
const app = Application.createFromDefinition({
|
||||
...anAppDefinition,
|
||||
deployedVersion: 0,
|
||||
versions: [
|
||||
{
|
||||
...anAppDefinition.versions[0],
|
||||
deployedImageName:
|
||||
"registry.example.org:996/captain/img-captain-nextcloud-cron:4",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(app.dockerImage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
115
services/Caprover.ts
Normal file
115
services/Caprover.ts
Normal file
@ -0,0 +1,115 @@
|
||||
type TODO_TypeDefinition = any;
|
||||
|
||||
class Application {
|
||||
private constructor(private readonly data: any) {}
|
||||
|
||||
static createFromDefinition(data: any): Application {
|
||||
return new Application(data);
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.data.appName;
|
||||
}
|
||||
get lastDeployedAt(): Date {
|
||||
return new Date(this.currentVersion.timeStamp);
|
||||
}
|
||||
get imageName(): string {
|
||||
return this.currentVersion.deployedImageName;
|
||||
}
|
||||
get dockerImage(): undefined | { name: string; tag: string; hubUrl: string } {
|
||||
const match = this.imageName.match(/^(.*):(.*)$/);
|
||||
if (!match || match[1].includes("/captain/")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const name = match[1];
|
||||
return {
|
||||
name: name,
|
||||
tag: match[2],
|
||||
hubUrl: name.includes("/")
|
||||
? `https://hub.docker.com/r/${name}`
|
||||
: `https://hub.docker.com/_/${name}`,
|
||||
};
|
||||
}
|
||||
|
||||
isOlderThan(days: number): boolean {
|
||||
const daysInMs = days * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
return now - this.lastDeployedAt.getTime() > daysInMs;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return JSON.stringify(this.data, null, 2);
|
||||
}
|
||||
|
||||
private get currentVersion(): TODO_TypeDefinition {
|
||||
return this.data.versions.find((version: { version: number }) => {
|
||||
return version.version === this.data.deployedVersion;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class Caprover {
|
||||
private authToken: string = "";
|
||||
private readonly apiUrl: string;
|
||||
|
||||
constructor(readonly domain?: string, private readonly password?: string) {
|
||||
if (!domain || !password) {
|
||||
throw new Error("Missing domain or password");
|
||||
}
|
||||
this.apiUrl = `https://${domain}/api/v2`;
|
||||
}
|
||||
|
||||
async getApps(): Promise<Application[]> {
|
||||
const res = await this.fetch("/user/apps/appDefinitions").then((res) =>
|
||||
res.json()
|
||||
);
|
||||
|
||||
return (
|
||||
res.data?.appDefinitions?.map(Application.createFromDefinition) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
private async authenticate() {
|
||||
if (this.authToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug("Trying to authenticate at", this.apiUrl);
|
||||
|
||||
const authResponse = await fetch(`${this.apiUrl}/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-namespace": "captain",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
password: this.password,
|
||||
}),
|
||||
}).then((res) => res.json());
|
||||
|
||||
this.authToken = authResponse?.data?.token;
|
||||
if (!this.authToken) {
|
||||
throw new Error(`Failed to authenticate at ${this.apiUrl}`);
|
||||
}
|
||||
console.debug("Authenticated successfully at", this.apiUrl, this.authToken);
|
||||
}
|
||||
|
||||
private async fetch(path: string, options?: RequestInit) {
|
||||
await this.authenticate();
|
||||
|
||||
console.debug("-> Caprover Fetching", path);
|
||||
return fetch(`${this.apiUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options?.headers || {}),
|
||||
"x-namespace": "captain",
|
||||
"x-captain-auth": this.authToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { Application };
|
||||
export default Caprover;
|
39
services/DockerHub.ts
Normal file
39
services/DockerHub.ts
Normal file
@ -0,0 +1,39 @@
|
||||
type Tag = {
|
||||
name: string;
|
||||
digest: string;
|
||||
};
|
||||
|
||||
class DockerHub {
|
||||
getLatestVersions = async (imageName: string): Promise<string[]> => {
|
||||
const imagePath = imageName.includes("/")
|
||||
? imageName
|
||||
: `library/${imageName}`;
|
||||
|
||||
const tagsResponse = await this.fetch(
|
||||
`/${imagePath}/tags?page_size=10&ordering=last_updated`
|
||||
).then((res) => res.json());
|
||||
|
||||
const tags: Tag[] = tagsResponse.results;
|
||||
if (tags.length === 0) {
|
||||
throw new Error(`No tags found for ${imageName}`);
|
||||
}
|
||||
|
||||
const latestTag = tags.find((tag) => tag.name === "latest");
|
||||
if (!latestTag) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const latestTags = tags
|
||||
.filter((tag) => tag.digest === latestTag.digest)
|
||||
.map((tag) => tag.name);
|
||||
return latestTags;
|
||||
};
|
||||
|
||||
private fetch = async (url: string): Promise<Response> => {
|
||||
const fullUrl = `https://hub.docker.com/v2/repositories${url}`;
|
||||
console.debug("-> Docker: Fetching", fullUrl);
|
||||
return fetch(fullUrl);
|
||||
};
|
||||
}
|
||||
|
||||
export default DockerHub;
|
9
tsconfig.json
Normal file
9
tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"types": ["bun-types"]
|
||||
}
|
||||
}
|
33
ui/Layout.ts
Normal file
33
ui/Layout.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export const html = String.raw;
|
||||
|
||||
const Layout = (content: string) => {
|
||||
return html`<html>
|
||||
<head>
|
||||
<title>Joe</title>
|
||||
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css" />
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/"><code>/</code></a>
|
||||
<a href="/applications">Applications</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>${content}</main>
|
||||
|
||||
<script>
|
||||
// add class "current" to all links matching the current URL
|
||||
const links = document.querySelectorAll("a");
|
||||
const currentUrl = window.location.pathname;
|
||||
links.forEach((link) => {
|
||||
if (link.getAttribute("href") === currentUrl) {
|
||||
link.classList.add("current");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
export default Layout;
|
Loading…
Reference in New Issue
Block a user