From 89535e0ae09d2a7d63c03b7ce53add715d920c36 Mon Sep 17 00:00:00 2001 From: Alice Brooks Date: Thu, 19 Sep 2024 11:08:13 +0100 Subject: [PATCH] Initial work towards subscriptions --- org.cockpit-project.starter-kit.metainfo.xml | 17 -- ...cockpit-project.subscriptions.metainfo.xml | 17 ++ package.json | 4 +- packit.yaml | 6 +- po/LINGUAS | 1 + src/app.scss | 1 + src/app.tsx | 170 ++++++++++++++---- src/backends/backend.tsx | 56 ++++++ src/backends/suseconnect.tsx | 96 ++++++++++ src/backends/transactional-update.tsx | 101 +++++++++++ src/components/reboot_dialog.tsx | 26 +++ src/components/register_code_form.tsx | 73 ++++++++ src/components/subscription_list.tsx | 65 +++++++ src/index.tsx | 3 +- src/manifest.json | 2 +- 15 files changed, 577 insertions(+), 61 deletions(-) delete mode 100644 org.cockpit-project.starter-kit.metainfo.xml create mode 100644 org.cockpit-project.subscriptions.metainfo.xml create mode 100644 po/LINGUAS create mode 100644 src/backends/backend.tsx create mode 100644 src/backends/suseconnect.tsx create mode 100644 src/backends/transactional-update.tsx create mode 100644 src/components/reboot_dialog.tsx create mode 100644 src/components/register_code_form.tsx create mode 100644 src/components/subscription_list.tsx diff --git a/org.cockpit-project.starter-kit.metainfo.xml b/org.cockpit-project.starter-kit.metainfo.xml deleted file mode 100644 index 8e19746..0000000 --- a/org.cockpit-project.starter-kit.metainfo.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - org.cockpit_project.starter_kit - CC0-1.0 - Starter Kit - Scaffolding for a cockpit module - -

- Scaffolding for a cockpit module. -

-
- org.cockpit_project.cockpit - starter-kit - https://github.com/cockpit-project/starter-kit - https://github.com/cockpit-project/starter-kit/issues - cockpit-devel_AT_lists.fedorahosted.org -
diff --git a/org.cockpit-project.subscriptions.metainfo.xml b/org.cockpit-project.subscriptions.metainfo.xml new file mode 100644 index 0000000..69f82ce --- /dev/null +++ b/org.cockpit-project.subscriptions.metainfo.xml @@ -0,0 +1,17 @@ + + + org.cockpit_project.subscriptions + CC0-1.0 + Subscriptions + Scaffolding for a cockpit module + +

+ Scaffolding for a cockpit module. +

+
+ org.cockpit_project.subscriptions + subscriptions + https://github.com/cockpit-project/subscriptions + https://github.com/cockpit-project/subscriptions/issues + cockpit-devel_AT_lists.fedorahosted.org +
diff --git a/package.json b/package.json index 1233485..c2d5a57 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "starter-kit", + "name": "subscriptions", "description": "Scaffolding for a cockpit module", "type": "module", "main": "index.js", - "repository": "git@github.com:cockpit/starter-kit.git", + "repository": "git@github.com:cockpit/subscriptions.git", "author": "", "license": "LGPL-2.1", "engines": { diff --git a/packit.yaml b/packit.yaml index 4f10932..588ef44 100644 --- a/packit.yaml +++ b/packit.yaml @@ -2,7 +2,7 @@ # To use this, enable Packit-as-a-service in GitHub: https://packit.dev/docs/packit-as-a-service/ # See https://packit.dev/docs/configuration/ for the format of this file -specfile_path: cockpit-starter-kit.spec +specfile_path: cockpit-subscriptions.spec # use the nicely formatted release description from our upstream release, instead of git shortlog copy_upstream_release_description: true @@ -12,12 +12,12 @@ srpm_build_deps: actions: post-upstream-clone: - - make cockpit-starter-kit.spec + - make cockpit-subscriptions.spec # replace Source1 manually, as create-archive: can't handle multiple tarballs - make node-cache - sh -c 'sed -i "/^Source1:/ s/https:.*/$(ls *-node*.tar.xz)/" cockpit-*.spec' create-archive: make dist - # starter-kit.git has no release tags; your project can drop this once you have a release + # subscriptions.git has no release tags; your project can drop this once you have a release get-current-version: make print-version jobs: diff --git a/po/LINGUAS b/po/LINGUAS new file mode 100644 index 0000000..7673daa --- /dev/null +++ b/po/LINGUAS @@ -0,0 +1 @@ +de diff --git a/src/app.scss b/src/app.scss index 6d2c5d8..ec86bb1 100644 --- a/src/app.scss +++ b/src/app.scss @@ -1,3 +1,4 @@ +@use "@patternfly/patternfly/patternfly-addons"; @use "page.scss"; p { diff --git a/src/app.tsx b/src/app.tsx index 0d3b12f..9fa8278 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,48 +1,144 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2017 Red Hat, Inc. - * - * Cockpit is free software; you can redistribute it and/or modify it - * under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation; either version 2.1 of the License, or - * (at your option) any later version. - * - * Cockpit is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Cockpit; If not, see . - */ - -import React, { useEffect, useState } from 'react'; -import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; +import React, { useCallback, useEffect, useState } from 'react'; import { Card, CardBody, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js"; import cockpit from 'cockpit'; - -const _ = cockpit.gettext; +import { Page, PageSection, PageSectionVariants } from '@patternfly/react-core'; +import { Backend, Extension, Subscription } from './backends/backend'; +import { TransactionalUpdate } from './backends/transactional-update'; +import { SuseConnect } from './backends/suseconnect'; +import { RegisterCodeForm, RegisterFormData } from './components/register_code_form'; +import { SubscriptionList } from './components/subscription_list'; +import { useDialogs } from 'dialogs'; +import { RebootDialog } from './components/reboot_dialog'; export const Application = () => { - const [hostname, setHostname] = useState(_("Unknown")); + const [backend, setBackend] = useState(null); + const [loadingSubscriptions, setLoadingSubscriptions] = useState(true); + const [loadingExtensions, setLoadingExtensions] = useState(true); + const [subscriptions, setSubscriptions] = useState([]); + const [unregisteredSubscriptions, setUnregisteredSubscriptions] = useState([]); + const [formData, setFormData] = useState({ + registrationCode: "", + email: "", + product: "", + }); + + const Dialogs = useDialogs(); + + useEffect(() => { + cockpit.spawn(["test", "-e", "/sbin/transactional-update"], { err: "ignore" }) + .then(() => setBackend(new TransactionalUpdate())) + .catch(() => { + cockpit.spawn(["test", "-e", "/usr/bin/suseconnect"], { err: "ignore" }) + .then(() => setBackend(new SuseConnect())); + }); + }, [setBackend]); + + const updateSubscriptions = useCallback(() => { + if (backend === null) { + return; + } + setLoadingSubscriptions(true); + + // handle get subscriptions + backend.getSubscriptions() + .then((subscriptions) => { + setSubscriptions(subscriptions); + setLoadingSubscriptions(false); + }); + + backend.getExtensions() + .then((subscriptions) => { + setUnregisteredSubscriptions(subscriptions); + setLoadingExtensions(false); + }); + }, [backend, setLoadingSubscriptions, setSubscriptions, setUnregisteredSubscriptions, setLoadingExtensions]); useEffect(() => { - const hostname = cockpit.file('/etc/hostname'); - hostname.watch(content => setHostname(content?.trim() ?? "")); - return hostname.close; - }, []); + updateSubscriptions(); + }, [updateSubscriptions, backend]); + + const registerProduct = async (): Promise<[boolean, string]> => { + console.debug("registering", formData); + const result = await backend?.register(formData.registrationCode, formData.email, formData.product).then((result) => { + if (result[0]) { + if (result[1].includes("Please reboot your machine")) { + // Show reboot modal + Dialogs.show(); + return result; + } + + updateSubscriptions(); + return result; + } + + return result; + }); + + return result || [false, ""]; + }; + + const deactivateProduct = (subscription: Subscription | Extension): void => { + console.log("deregistering", subscription.identifier); + setLoadingSubscriptions(true); + backend?.deregister([subscription.identifier, subscription.version, subscription.arch].join("/")) + .then(async (output) => { + console.log(output); + if (output.includes("Can not deregister base product")) { + console.log("deregistering base"); + output = await backend.deregister(); + console.log("base deregistered"); + } + if (output.includes("Please reboot your machine")) { + Dialogs.show(); + } + console.log("deregistered"); + setLoadingSubscriptions(false); + updateSubscriptions(); + }); + }; + + const activateProduct = (subscription: Subscription | Extension): void => { + console.log("activating", subscription.identifier); + setLoadingExtensions(true); + backend?.register("", "", [subscription.identifier, subscription.version, subscription.arch].join("/")) + .then(async (result) => { + if (result[0]) { + if (result[1].includes("Please reboot your machine")) { + // Show reboot modal + Dialogs.show(); + } + } + console.log("activated"); + setLoadingExtensions(false); + updateSubscriptions(); + }); + }; return ( - - Starter Kit - - - - + + + + Register a new subscription + + + + + + Registered Subscriptions + + + + + {unregisteredSubscriptions.length + ? + Available Extensions + + + + + : ""} + + ); }; diff --git a/src/backends/backend.tsx b/src/backends/backend.tsx new file mode 100644 index 0000000..eb56dab --- /dev/null +++ b/src/backends/backend.tsx @@ -0,0 +1,56 @@ +type Extension = { + name: string, + identifier: string, + version: string, + arch: string, + activated: boolean, + available: boolean, + free: boolean + extensions: Extension[], + expires_at?: string, +} + +enum SubscriptionStatus { + Active = "Active", + Expired = "Expired", + Unregistered = "Unregistered", +} + +type Subscription = { + name?: string, + identifier: string, + version: string, + arch: string, + status: string, + regcode?: string, + starts_at?: string, + expires_at?: string, + subscription_status?: SubscriptionStatus, + type?: string, + extensions?: Extension[], +} + +// Cockpits typescript types don't contain this +// Just a more slimmed down BasicError, might be worth upstreaming +type CockpitSpawnError = { + message: string, + problem: string | null, + exit_status: number | null, + exit_signal: string | null, +} + +enum SUSEConnectExitCodes { + ZyppBusy = 7, +} + +interface Backend { + getSubscriptions(): Promise; + + getExtensions(): Promise; + + register(reg_code?: string, email?: string, product?: string): Promise<[boolean, string]>; + + deregister(product?: string): Promise; +} + +export { Backend, Subscription, SubscriptionStatus, Extension, CockpitSpawnError, SUSEConnectExitCodes }; diff --git a/src/backends/suseconnect.tsx b/src/backends/suseconnect.tsx new file mode 100644 index 0000000..f352c06 --- /dev/null +++ b/src/backends/suseconnect.tsx @@ -0,0 +1,96 @@ +import cockpit from 'cockpit'; +import { Backend, Subscription, CockpitSpawnError, SUSEConnectExitCodes, Extension } from './backend'; + +export class SuseConnect implements Backend { + async getSubscriptions(): Promise { + let result; + let retry = true; + let tries = 0; + + // Zypper is busy on pageload due to other cockpit actions + // so we need to implement some basic retrying + while (retry && tries <= 20) { + await this.getSubscriptionsStatus() + .then((response) => { + retry = false; + result = JSON.parse(response).filter((product: Subscription) => product.status !== "Not Registered"); + }) + .catch((error: CockpitSpawnError) => { + tries++; + if (error.exit_status !== SUSEConnectExitCodes.ZyppBusy) { + retry = false; + } + }); + } + + return result || []; + } + + async getSubscriptionsStatus(): Promise { + // Since suseconnect is just used inside transactional-update we can + // skip the subvolume setup and save alot of loadtime + return cockpit.spawn(["suseconnect", "-s"], { superuser: "require" }); + } + + async getExtensions(): Promise { + let result; + let retry = true; + let tries = 0; + + // Zypper is busy on pageload due to other cockpit actions + // so we need to implement some basic retrying + while (retry && tries <= 20) { + await this.getAvailableExtensions() + .then((response) => { + retry = false; + result = (JSON.parse(response).extensions || []).filter((product: Extension) => product.free === true && product.available === true && product.activated === false); + }) + .catch((error: CockpitSpawnError) => { + tries++; + if (error.exit_status !== SUSEConnectExitCodes.ZyppBusy) { + retry = false; + } + }); + } + + return result || []; + } + + async getAvailableExtensions(): Promise { + // Since suseconnect is just used inside transactional-update we can + // skip the subvolume setup and save alot of loadtime + return cockpit.spawn(["suseconnect", "--json", "-l"], { superuser: "require" }); + } + + async register(reg_code: string, email: string, product: string): Promise<[boolean, string]> { + console.debug("attempting to register system"); + let emailOption = ""; + if (email !== "") { + emailOption = "-e " + email; + } + let productOption = ""; + if (product !== "") { + productOption = "-p " + product; + } + console.log(["suseconnect", "-r", reg_code, emailOption, productOption].join(" ")); + return cockpit.spawn(["suseconnect", "-r", reg_code, emailOption, productOption], { superuser: "require" }) + .then((result): [boolean, string] => { + console.debug("registration result", result); + return [result.includes("Successfully registered system"), ""]; + }) + .catch((error: CockpitSpawnError, data?: string): [boolean, string] => { + console.error("Failed to register system with", error); + return [false, data || error.toString()]; + }); + } + + async deregister(product?: string): Promise { + let productOption = ""; + if (product) { + productOption = "-p " + product; + } + + console.log(["suseconnect", "-d", productOption].join(" ")); + return cockpit.spawn(["suseconnect", "-d", productOption], { superuser: "require" }); + } +} diff --git a/src/backends/transactional-update.tsx b/src/backends/transactional-update.tsx new file mode 100644 index 0000000..0a335ac --- /dev/null +++ b/src/backends/transactional-update.tsx @@ -0,0 +1,101 @@ +import cockpit from 'cockpit'; +import { Backend, Subscription, CockpitSpawnError, SUSEConnectExitCodes, Extension } from './backend'; + +export class TransactionalUpdate implements Backend { + async getSubscriptions(): Promise { + let result; + let retry = true; + let tries = 0; + + // Zypper is busy on pageload due to other cockpit actions + // so we need to implement some basic retrying + while (retry && tries <= 20) { + console.log("attempting"); + await this.getSubscriptionsStatus() + .then((response) => { + retry = false; + result = JSON.parse(response).filter((product: Subscription) => product.status !== "Not Registered"); + }) + .catch((error: CockpitSpawnError) => { + tries++; + if (error.exit_status !== SUSEConnectExitCodes.ZyppBusy) { + retry = false; + } + }); + } + + return result || []; + } + + async getSubscriptionsStatus(): Promise { + // Since suseconnect is just used inside transactional-update we can + // skip the subvolume setup and save alot of loadtime + return cockpit.spawn(["suseconnect", "-s"], { superuser: "require" }); + } + + async getExtensions(): Promise { + let result; + let retry = true; + let tries = 0; + console.log("getting extensions"); + + // Zypper is busy on pageload due to other cockpit actions + // so we need to implement some basic retrying + while (retry && tries <= 20) { + await this.getAvailableExtensions() + .then((response) => { + retry = false; + result = (JSON.parse(response).extensions || []).filter((product: Extension) => product.free === true && product.available === true && product.activated === false); + console.log("got extensions", result); + }) + .catch((error: CockpitSpawnError) => { + tries++; + if (error.exit_status !== SUSEConnectExitCodes.ZyppBusy) { + retry = false; + } + }); + } + + return result || []; + } + + async getAvailableExtensions(): Promise { + // Since suseconnect is just used inside transactional-update we can + // skip the subvolume setup and save alot of loadtime + return cockpit.spawn(["suseconnect", "--json", "-l"], { superuser: "require" }); + } + + async register(reg_code: string, email: string, product: string): Promise<[boolean, string]> { + console.debug("attempting to register system"); + let regOption = ""; + if (reg_code !== "") { + regOption = "-r " + reg_code; + } + let emailOption = ""; + if (email !== "") { + emailOption = "-e " + email; + } + let productOption = ""; + if (product !== "") { + productOption = "-p " + product; + } + return cockpit.spawn(["transactional-update", "--no-selfupdate", "-n", "-d", "register", regOption, emailOption, productOption], { superuser: "require" }) + .then((result): [boolean, string] => { + console.debug("registration result", result); + return [result.includes("Successfully registered system"), result]; + }) + .catch((error: CockpitSpawnError, data?: string): [boolean, string] => { + console.error("Failed to register system with", error); + return [false, data || error.toString()]; + }); + } + + async deregister(product?: string): Promise { + let productOption = ""; + if (product) { + productOption = "-p " + product; + } + + return cockpit.spawn(["transactional-update", "--no-selfupdate", "-d", "register", "-d", productOption], { superuser: "require" }); + } +} diff --git a/src/components/reboot_dialog.tsx b/src/components/reboot_dialog.tsx new file mode 100644 index 0000000..2901178 --- /dev/null +++ b/src/components/reboot_dialog.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Button, Modal } from '@patternfly/react-core'; +import cockpit from 'cockpit'; + +import { useDialogs } from 'dialogs.jsx'; + +const _ = cockpit.gettext; + +export const RebootDialog = () => { + const Dialogs = useDialogs(); + + const reboot = () => { + cockpit.spawn(["reboot"], { superuser: "try" }); + }; + + return ( + Reboot + } + > +

{_("This requires a reboot to take effect, if you don't reboot now this change might not be included")}

+
+ ); +}; diff --git a/src/components/register_code_form.tsx b/src/components/register_code_form.tsx new file mode 100644 index 0000000..08ba80c --- /dev/null +++ b/src/components/register_code_form.tsx @@ -0,0 +1,73 @@ +import cockpit from 'cockpit'; +import { Form, Grid, FormGroup, TextInput, GridItem, ActionGroup, Button } from "@patternfly/react-core"; +import React, { useState } from "react"; +import { EmptyStatePanel } from 'cockpit-components-empty-state'; + +const _ = cockpit.gettext; + +type RegisterFormData = { + registrationCode: string, + email: string, + product: string, +}; + +type Props = { + submitCallback: any, + formData: RegisterFormData, + setFormData: any, +}; + +const RegisterCodeForm = ({ submitCallback, formData, setFormData }: Props) => { + const [submitting, setSubmitting] = useState(false); + + const onValueChange = (fieldName: string, value: any) => { + setFormData({ ...formData, [fieldName]: value }); + }; + + const submit = () => { + setSubmitting(true); + submitCallback().then((success: boolean) => { + if (success) { + setFormData({ + registrationCode: "", + email: "", + product: "", + }); + } + + setSubmitting(false); + }); + }; + + if (submitting) + return ; + + return ( +
+ + + onValueChange("registrationCode", value)} value={formData.registrationCode} + /> + + + onValueChange("email", value)} value={formData.email} + /> + + + onValueChange("product", value)} value={formData.product} + /> + + + + + + + +
+ ); +}; + +export { RegisterCodeForm, RegisterFormData }; diff --git a/src/components/subscription_list.tsx b/src/components/subscription_list.tsx new file mode 100644 index 0000000..9dc4471 --- /dev/null +++ b/src/components/subscription_list.tsx @@ -0,0 +1,65 @@ +import cockpit from 'cockpit'; +import React from "react"; +import { Badge, Button, Flex, FlexItem, List, ListItem } from "@patternfly/react-core"; +import { Extension, Subscription } from "../backends/backend"; +import { EmptyStatePanel } from "cockpit-components-empty-state"; + +const _ = cockpit.gettext; + +type Props = { + subscriptions: Subscription[] | Extension[], + loading: boolean, + deactivate?: (subscription: Subscription | Extension) => void, + activate?: (subscription: Subscription | Extension) => void, +}; + +export const SubscriptionList = ({ subscriptions, loading, deactivate, activate }: Props) => { + const format_date = (date: string): string => { + const dateObj = new Date(Date.parse(date)); + + return dateObj.toISOString().split("T")[0].split("-").reverse() + .join('-'); + }; + + if (loading) + return ; + + return ( + + {subscriptions.map((item: Subscription | Extension) => { + return ( + + + + {item.identifier} + + {item.version} + + + {item.arch} + + {item.expires_at + ? + Expires: {format_date(item.expires_at)} + + : ""} + + + {deactivate + ? + : ""} + {activate + ? + : ""} + + + + ); + })} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index 51254c0..58f34c7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -26,7 +26,8 @@ import { Application } from './app.jsx'; import "patternfly/patternfly-5-cockpit.scss"; import './app.scss'; +import { WithDialogs } from 'dialogs.js'; document.addEventListener("DOMContentLoaded", () => { - createRoot(document.getElementById("app")!).render(); + createRoot(document.getElementById("app")!).render(); }); diff --git a/src/manifest.json b/src/manifest.json index 3a45f56..ae30ad1 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -5,7 +5,7 @@ "tools": { "index": { - "label": "Starter Kit" + "label": "Subscriptions" } } }