diff --git a/Makefile b/Makefile index 1bfb05c..2b9fe50 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # extract name from package.json PACKAGE_NAME := $(shell awk '/"name":/ {gsub(/[",]/, "", $$2); print $$2}' package.json) -RPM_NAME := cockpit-$(PACKAGE_NAME) +RPM_NAME := $(PACKAGE_NAME) VERSION := $(shell T=$$(git describe 2>/dev/null) || T=1; echo $$T | tr '-' '.') ifeq ($(TEST_OS),) TEST_OS = centos-9-stream @@ -10,7 +10,7 @@ TARFILE=$(RPM_NAME)-$(VERSION).tar.xz NODE_CACHE=$(RPM_NAME)-node-$(VERSION).tar.xz SPEC=$(RPM_NAME).spec PREFIX ?= /usr/local -APPSTREAMFILE=org.cockpit_project.$(subst -,_,$(PACKAGE_NAME)).metainfo.xml +APPSTREAMFILE=org.opensuse.$(subst -,_,$(PACKAGE_NAME)).metainfo.xml VM_IMAGE=$(CURDIR)/test/images/$(TEST_OS) # stamp file to check for node_modules/ NODE_MODULES_TEST=package-lock.json diff --git a/org.cockpit_project.starter_kit.metainfo.xml b/org.cockpit_project.starter_kit.metainfo.xml deleted file mode 100644 index 4430d28..0000000 --- a/org.cockpit_project.starter_kit.metainfo.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - org.cockpit_project.starter_kit - CC0-1.0 - Starter Kit - Scaffolding for a cockpit module - - - Scaffolding for a cockpit module. - - This is just a demo which does not do much. Please replace - this with a real description. - - - 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 - - Cockpit Project - - diff --git a/org.opensuse.cockpit_repos.metainfo.xml b/org.opensuse.cockpit_repos.metainfo.xml new file mode 100644 index 0000000..078a8fa --- /dev/null +++ b/org.opensuse.cockpit_repos.metainfo.xml @@ -0,0 +1,19 @@ + + + org.opensuse.cockpit-repos.metainfo.xml + CC0-1.0 + Repositories + A cockpit module for managing repositories. + + + A cockpit module for managing repositories. + + + org.cockpit_project.cockpit + repositories + https://github.com/openSUSE/cockpit-repos + https://github.com/openSUSE/cockpit-repos/issues + + openSUSE + + diff --git a/package.json b/package.json index f03a930..d5da9b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "starter-kit", - "description": "Scaffolding for a cockpit module", + "name": "cockpit-repos", + "description": "A cockpit module for managing system repositories", "type": "module", "main": "index.js", "repository": "git@github.com:cockpit/starter-kit.git", @@ -54,6 +54,7 @@ "@patternfly/react-core": "5.4.11", "@patternfly/react-icons": "5.4.2", "@patternfly/react-styles": "5.4.1", + "@patternfly/react-table": "^6.1.0", "react": "18.3.1", "react-dom": "18.3.1" } diff --git a/packaging/cockpit-starter-kit.spec.in b/packaging/cockpit-repos.spec.in similarity index 98% rename from packaging/cockpit-starter-kit.spec.in rename to packaging/cockpit-repos.spec.in index 14cd470..c4dce16 100644 --- a/packaging/cockpit-starter-kit.spec.in +++ b/packaging/cockpit-repos.spec.in @@ -1,4 +1,4 @@ -Name: cockpit-starter-kit +Name: cockpit-repos Version: %{VERSION} Release: 1%{?dist} Summary: Cockpit Starter Kit Example Module diff --git a/packit.yaml b/packit.yaml index 4f10932..918c4ea 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-repos.spec # use the nicely formatted release description from our upstream release, instead of git shortlog copy_upstream_release_description: true diff --git a/src/app.scss b/src/app.scss index 6d2c5d8..96ea863 100644 --- a/src/app.scss +++ b/src/app.scss @@ -1,5 +1,17 @@ +@use "@patternfly/patternfly/patternfly-addons"; @use "page.scss"; p { font-weight: bold; } + +.pf-v6-c-table tr { + > td, th { + --pf-v5-c-table--cell--PaddingTop: var(--pf-v5-global--spacer--xs); + --pf-v5-c-table--cell--PaddingBottom: var(--pf-v5-global--spacer--xs); + + padding-block: var(--pf-v5-c-table--cell--PaddingTop) var(--pf-v5-c-table--cell--PaddingBottom); + + padding-inline: var(--pf-v5-c-table--cell--PaddingLeft) var(--pf-v5-c-table--cell--PaddingRight); + } +} diff --git a/src/app.tsx b/src/app.tsx index 0d3b12f..34e1228 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,47 +1,94 @@ -/* - * 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 { Card, CardBody, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js"; - -import cockpit from 'cockpit'; +import React, { + createContext, + Dispatch, + SetStateAction, + useContext, + useEffect, + useState, +} from "react"; +import { + Card, + CardBody, + CardHeader, + CardTitle, +} from "@patternfly/react-core/dist/esm/components/Card/index.js"; + +import cockpit from "cockpit"; +import { Zypp } from "./backends/zypp"; +import { Backend, Repo } from "./backends/backend"; +import { RepoList } from "./components/repo_list"; +import { Button } from "@patternfly/react-core"; +import { useDialogs, WithDialogs } from "dialogs"; +import { RepoDialog } from "./components/repo_dialog"; +import { EmptyStatePanel } from "cockpit-components-empty-state"; const _ = cockpit.gettext; +export const RepoChangesContext = createContext<{ + reposChanged: number | null; + setReposChanged: Dispatch> | null; +}>({ + reposChanged: null, + setReposChanged: null, +}); + export const Application = () => { - const [hostname, setHostname] = useState(_("Unknown")); + const [reposChanged, setReposChanged] = useState(0); + + return ( + + + + + + ); +}; + +const RepoCard = () => { + const [backend, _setBackend] = useState(new Zypp()); + const [repos, setRepos] = useState([]); + const { reposChanged, setReposChanged } = useContext(RepoChangesContext); + + const Dialogs = useDialogs(); useEffect(() => { - const hostname = cockpit.file('/etc/hostname'); - hostname.watch(content => setHostname(content?.trim() ?? "")); - return hostname.close; - }, []); + backend.getRepos().then((repos) => { + setRepos(repos); + }); + }, [backend, reposChanged]); return ( - Starter Kit + + Dialogs.show()} + > + {_("Add Repo")} + + ), + }} + > + {_("Software Repositories")} + - + {repos + ? ( + + ) + : ( + + )} ); diff --git a/src/backends/backend.ts b/src/backends/backend.ts new file mode 100644 index 0000000..8abe251 --- /dev/null +++ b/src/backends/backend.ts @@ -0,0 +1,34 @@ +// enum RepoType { +// ftp, +// http, +// https, +// smb_cifs, +// nfs, +// cd, +// dvd, +// hard_disk, +// usb, +// local_directory, +// local_iso_image, +// } + +type Repo = { + index: number, + alias: string, + name: string, + // type: RepoType, + priority: number, + enabled: boolean, + autorefresh: boolean, + gpgcheck: boolean, + uri: string, +} + +interface Backend { + getRepos(): Promise, + addRepo(repo: Repo): Promise + deleteRepo(repo: Repo): Promise + modifyRepo(repo: Repo): Promise +} + +export { Repo, Backend }; diff --git a/src/backends/zypp.ts b/src/backends/zypp.ts new file mode 100644 index 0000000..70449c2 --- /dev/null +++ b/src/backends/zypp.ts @@ -0,0 +1,75 @@ +import cockpit from "cockpit"; + +import { Backend, Repo } from "./backend"; + +export class Zypp implements Backend { + deleteRepo(repo: Repo): Promise { + return cockpit.spawn(["zypper", "removerepo", repo.index.toString()], { superuser: "require" }); + } + + async getRepos(): Promise { + return cockpit.spawn(["zypper", "--xmlout", "repos"]).then((response) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(response, "text/xml"); + let index = 1; + const repos = Array.from(doc.documentElement.querySelectorAll("repo")).map( + (repo): Repo => { + const definedRepo = { + index, + alias: repo.getAttribute("alias") || "", + name: repo.getAttribute("name") || "", + priority: parseInt(repo.getAttribute("priority") || ""), + enabled: repo.getAttribute("enabled") === "1", + autorefresh: repo.getAttribute("autorefresh") === "1", + gpgcheck: repo.getAttribute("gpgcheck") === "1", + uri: repo.querySelector("url")?.textContent || "", + }; + + index++; + return definedRepo; + }, + ); + return repos; + }); + } + + addRepo(repo: Repo): Promise { + const args = ["-n", repo.name, "-p", repo.priority.toString()]; + if (repo.enabled) { + args.push("--enable"); + } else { + args.push("--disable"); + } + if (repo.autorefresh) { + args.push("--refresh"); + } else { + args.push("--no-refresh"); + } + if (repo.gpgcheck) { + args.push("--gpgcheck"); + } else { + args.push("--no-gpgcheck"); + } + return cockpit.spawn(["zypper", "addrepo", ...args, repo.uri, repo.alias], { superuser: "require" }); + } + + modifyRepo(repo: Repo): Promise { + const args = ["-n", repo.name, "-p", repo.priority.toString()]; + if (repo.enabled) { + args.push("--enable"); + } else { + args.push("--disable"); + } + if (repo.autorefresh) { + args.push("--refresh"); + } else { + args.push("--no-refresh"); + } + if (repo.gpgcheck) { + args.push("--gpgcheck"); + } else { + args.push("--no-gpgcheck"); + } + return cockpit.spawn(["zypper", "modifyrepo", ...args, repo.index.toString()], { superuser: "require" }); + } +} diff --git a/src/components/repo_dialog.tsx b/src/components/repo_dialog.tsx new file mode 100644 index 0000000..f2b9e46 --- /dev/null +++ b/src/components/repo_dialog.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Modal } from "@patternfly/react-core"; +import cockpit from "cockpit"; + +import { useDialogs } from "dialogs.jsx"; +import RepoForm from "./repo_form"; +import { Backend, Repo } from "../backends/backend"; + +const _ = cockpit.gettext; + +export const RepoDialog = ({ + backend, + repo, +}: { + backend: Backend; + repo: null | Repo; +}) => { + const Dialogs = useDialogs(); + + return ( + + + + ); +}; diff --git a/src/components/repo_form.tsx b/src/components/repo_form.tsx new file mode 100644 index 0000000..5630506 --- /dev/null +++ b/src/components/repo_form.tsx @@ -0,0 +1,150 @@ +import { + ActionGroup, + Button, + Checkbox, + Form, + FormGroup, + TextInput, +} from "@patternfly/react-core"; +import React, { useCallback, useContext, useEffect, useState } from "react"; +import { Backend, Repo } from "../backends/backend"; +import { EmptyStatePanel } from 'cockpit-components-empty-state'; + +import cockpit from "cockpit"; +import { RepoChangesContext } from "../app"; + +const _ = cockpit.gettext; + +const RepoForm = ({ + backend, + repo, + close, +}: { + backend: Backend; + repo: null | Repo; + close: () => void; +}) => { + const { reposChanged, setReposChanged } = useContext(RepoChangesContext); + const [editing, setEditing] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [formData, setFormData] = useState({ + index: 0, + alias: "", + name: "", + priority: 99, + enabled: true, + autorefresh: true, + gpgcheck: true, + uri: "", + }); + + useEffect(() => { + console.log(reposChanged); + }, [reposChanged]); + + const onValueChange = useCallback( + (fieldName: string, value: string | number | boolean) => { + setFormData({ ...formData, [fieldName]: value }); + }, + [setFormData, formData], + ); + + const submit = useCallback(() => { + if (!submitting) { + // Add repo + setSubmitting(true); + let callback: Promise; + if (editing) { + callback = backend.modifyRepo(formData); + } else { + callback = backend.addRepo(formData); + } + callback.then((response) => { + console.log(response); + console.log(reposChanged, setReposChanged); + if (setReposChanged !== null && reposChanged !== null) + setReposChanged(reposChanged + 1); + setSubmitting(false); + close(); + }); + } + }, [submitting, formData, reposChanged, setReposChanged]); + + useEffect(() => { + if (repo) { + setFormData(repo); + setEditing(true); + } + }, [repo]); + + if (submitting) + return ; + + return ( + + + onValueChange("alias", value)} + value={formData.alias} + placeholder="" + /> + + + onValueChange("name", value)} + value={formData.name} + placeholder="" + /> + + + onValueChange("priority", value)} + value={formData.priority} + placeholder="99" + /> + + + onValueChange("enabled", value)} + isChecked={formData.enabled} + /> + + + onValueChange("autorefresh", value)} + isChecked={formData.autorefresh} + /> + + + onValueChange("gpgcheck", value)} + isChecked={formData.gpgcheck} + /> + + + onValueChange("uri", value)} + value={formData.uri} + placeholder="" + /> + + + + {_("Save")} + + + + ); +}; + +export default RepoForm; diff --git a/src/components/repo_list.tsx b/src/components/repo_list.tsx new file mode 100644 index 0000000..1f0b789 --- /dev/null +++ b/src/components/repo_list.tsx @@ -0,0 +1,103 @@ +import React, { useContext } from "react"; +import { Backend, Repo } from "../backends/backend"; +import { ListingTable } from "cockpit-components-table.jsx"; +import { KebabDropdown } from "cockpit-components-dropdown"; + +import cockpit from "cockpit"; +import { DropdownItem } from "@patternfly/react-core"; +import { useDialogs } from "dialogs"; +import { BanIcon, CheckIcon } from "@patternfly/react-icons"; +import { RepoDialog } from "./repo_dialog"; +import { RepoChangesContext } from "../app"; + +const _ = cockpit.gettext; + +type Props = { + repos: Repo[]; + backend: Backend; +}; + +export const RepoList = ({ repos, backend }: Props) => { + const columns = [ + { title: _("Alias") }, + { title: _("Name") }, + { title: _("Priority") }, + { title: _("GPG Check") }, + { title: _("Autorefresh") }, + { title: _("Enabled") }, + ]; + + return ( + 0} + rows={repos.map((repo) => { + return { + columns: [ + { + title: repo.alias, + props: { width: 20 }, + }, + { + title: repo.name, + props: { width: 40 }, + }, + { + title: repo.priority, + props: { width: 10 }, + }, + { + title: repo.gpgcheck ? : , + props: { width: 10 }, + }, + { + title: repo.autorefresh ? : , + props: { width: 10 }, + }, + { + title: repo.enabled ? : , + props: { width: 10 }, + }, + { + title: , + props: { className: "pf-v5-c-table__action" }, + }, + ], + props: { key: repo.alias }, + }; + })} + loading={repos.length ? "" : _("Loading...")} + variant="compact" + /> + ); +}; + +const RepoActions = ({ backend, repo }: { backend: Backend; repo: Repo }) => { + const Dialogs = useDialogs(); + const { reposChanged, setReposChanged } = useContext(RepoChangesContext); + + const actions = [ + Dialogs.show()} + > + {_("Edit repo")} + , + { + backend.deleteRepo(repo).then(() => { + if (setReposChanged && reposChanged !== null) + setReposChanged(reposChanged + 1); + }); + }} + > + {_("Delete repo")} + , + ]; + + return ( + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index 51254c0..c13da99 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,15 +17,14 @@ * along with Cockpit; If not, see . */ -import React from 'react'; -import { createRoot } from 'react-dom/client'; +import React from "react"; +import { createRoot } from "react-dom/client"; import "cockpit-dark-theme"; -import { Application } from './app.jsx'; - import "patternfly/patternfly-5-cockpit.scss"; -import './app.scss'; +import "./app.scss"; +import { Application } from "./app"; document.addEventListener("DOMContentLoaded", () => { createRoot(document.getElementById("app")!).render(); diff --git a/src/manifest.json b/src/manifest.json index 3a45f56..536de28 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -5,7 +5,7 @@ "tools": { "index": { - "label": "Starter Kit" + "label": "Repositories" } } } diff --git a/test/check-application b/test/check-application index 3e85d0f..b08fc6a 100755 --- a/test/check-application +++ b/test/check-application @@ -16,13 +16,13 @@ class TestApplication(testlib.MachineCase): b = self.browser m = self.machine - self.login_and_go("/starter-kit") + m.execute("zypper --non-interactive addrepo https://test/ test-repo") + self.login_and_go("/cockpit-repos") # verify expected heading - b.wait_text(".pf-v5-c-card__title", "Starter Kit") + b.wait_text(".pf-v5-c-card__title", "Software Repositories") # verify expected host name - hostname = m.execute("cat /etc/hostname").strip() - b.wait_in_text(".pf-v5-c-alert__title", "Running on " + hostname) + b.wait_in_text("#repos-list", "test-repo") # change current hostname self.write_file("/etc/hostname", "new-" + hostname)
- Scaffolding for a cockpit module. - - This is just a demo which does not do much. Please replace - this with a real description. -
+ A cockpit module for managing repositories. +