Implement versions page on crates view
Diff
chartered-db/src/crates.rs | 20 +++++++++++++++-----
chartered-db/src/schema.rs | 4 ++++
chartered-frontend/src/index.sass | 3 +++
migrations/2021-08-31-214501_create_crates_table/up.sql | 4 ++++
chartered-frontend/src/pages/crate/CrateView.tsx | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx | 36 ++++++++++++++++++++++++++++++++++--
chartered-web/src/endpoints/cargo_api/publish.rs | 4 +++-
chartered-web/src/endpoints/web_api/ssh_key.rs | 10 +++++-----
chartered-web/src/endpoints/web_api/crates/info.rs | 21 +++++++++++++++++++--
9 files changed, 203 insertions(+), 24 deletions(-)
@@ -1,7 +1,7 @@
use crate::users::UserCratePermission;
use crate::users::{UserCratePermission, User};
use super::{
schema::{crate_versions, crates},
schema::{crate_versions, crates, users},
BitwiseExpressionMethods, ConnectionPool, Result,
};
use diesel::{insert_into, prelude::*, Associations, Identifiable, Queryable};
@@ -74,14 +74,14 @@
.await?
}
pub async fn versions(
pub async fn versions_with_uploader(
self: Arc<Self>,
conn: ConnectionPool,
) -> Result<Vec<CrateVersion<'static>>> {
) -> Result<Vec<(CrateVersion<'static>, User)>> {
tokio::task::spawn_blocking(move || {
let conn = conn.get()?;
Ok(CrateVersion::belonging_to(&*self).load::<CrateVersion>(&conn)?)
Ok(CrateVersion::belonging_to(&*self).inner_join(users::table).load::<(CrateVersion, User)>(&conn)?)
})
.await?
}
@@ -216,14 +216,16 @@
pub async fn publish_version(
self: Arc<Self>,
conn: ConnectionPool,
user: Arc<User>,
file_identifier: chartered_fs::FileReference,
file_checksum: String,
file_size: i32,
given: chartered_types::cargo::CrateVersion<'static>,
metadata: chartered_types::cargo::CrateVersionMetadata,
) -> Result<()> {
use crate::schema::crate_versions::dsl::{
checksum, crate_id, crate_versions, dependencies, features, filesystem_object, links,
version,
version, size, user_id,
};
use crate::schema::crates::dsl::{
crates, description, documentation, homepage, id, readme, repository,
@@ -247,11 +249,13 @@
.values((
crate_id.eq(self.id),
filesystem_object.eq(file_identifier.to_string()),
size.eq(file_size),
checksum.eq(file_checksum),
version.eq(given.vers),
dependencies.eq(CrateDependencies(given.deps)),
features.eq(CrateFeatures(given.features)),
links.eq(given.links),
user_id.eq(user.id),
))
.execute(&conn)?;
@@ -290,16 +294,20 @@
#[derive(Identifiable, Queryable, Associations, PartialEq, Debug)]
#[belongs_to(Crate)]
#[belongs_to(User)]
pub struct CrateVersion<'a> {
pub id: i32,
pub crate_id: i32,
pub version: String,
pub filesystem_object: String,
pub size: i32,
pub yanked: bool,
pub checksum: String,
pub dependencies: CrateDependencies<'a>,
pub features: CrateFeatures,
pub links: Option<String>,
pub user_id: i32,
pub created_at: chrono::NaiveDateTime,
}
impl<'a> CrateVersion<'a> {
@@ -1,14 +1,17 @@
table! {
crate_versions (id) {
id -> Integer,
crate_id -> Integer,
version -> Text,
filesystem_object -> Text,
size -> Integer,
yanked -> Bool,
checksum -> Text,
dependencies -> Binary,
features -> Binary,
links -> Nullable<Text>,
user_id -> Integer,
created_at -> Timestamp,
}
}
@@ -66,6 +69,7 @@
}
joinable!(crate_versions -> crates (crate_id));
joinable!(crate_versions -> users (user_id));
joinable!(user_crate_permissions -> crates (crate_id));
joinable!(user_crate_permissions -> users (user_id));
joinable!(user_sessions -> user_ssh_keys (user_ssh_key_id));
@@ -19,3 +19,6 @@
td.fit, th.fit
width: 0.1%
white-space: nowrap
.text-decoration-underline-dotted
text-decoration: underline dotted
@@ -13,12 +13,16 @@
crate_id INTEGER NOT NULL,
version VARCHAR(255) NOT NULL,
filesystem_object VARCHAR(255) NOT NULL,
size INTEGER NOT NULL,
yanked BOOLEAN NOT NULL DEFAULT FALSE,
checksum VARCHAR(255) NOT NULL,
dependencies BLOB NOT NULL,
features BLOB NOT NULL,
links VARCHAR(255),
user_id INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (crate_id, version),
FOREIGN KEY (user_id) REFERENCES users (id)
FOREIGN KEY (crate_id) REFERENCES crates (id)
);
@@ -6,7 +6,18 @@
import Nav from "../../sections/Nav";
import Loading from "../Loading";
import ErrorPage from "../ErrorPage";
import { Box, HouseDoor, Book, Building } from "react-bootstrap-icons";
import {
BoxSeam,
HouseDoor,
Book,
Building,
Calendar3,
Check2Square,
Hdd,
CheckSquare,
Check,
Square,
} from "react-bootstrap-icons";
import { useParams } from "react-router-dom";
import { useAuthenticatedRequest } from "../../util";
@@ -14,6 +25,8 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Members from "./Members";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import HumanTime from "react-human-time";
type Tab = "readme" | "versions" | "members";
@@ -30,6 +43,10 @@
export interface CrateInfoVersion {
vers: string;
deps: CrateInfoVersionDependency[];
features: any[];
size: number;
uploader: string;
created_at: string;
}
export interface CrateInfoVersionDependency {
@@ -70,7 +87,7 @@
className="text-white circle bg-primary bg-gradient d-inline rounded-circle d-inline-flex justify-content-center align-items-center"
style={{ width: "2rem", height: "2rem" }}
>
<Box />
<BoxSeam />
</div>
<h1 className="text-primary d-inline px-2">{crate}</h1>
<h2 className="text-secondary m-0">{crateVersion.vers}</h2>
@@ -149,7 +166,11 @@
<div className="card-body">
{currentTab == "readme" ? <ReadMe crate={crateInfo} /> : <></>}
{currentTab == "versions" ? <>Versions</> : <></>}
{currentTab == "versions" ? (
<Versions crate={crateInfo} />
) : (
<></>
)}
{currentTab == "members" ? <Members crate={crate} /> : <></>}
</div>
</div>
@@ -187,6 +208,104 @@
</div>
</div>
</div>
</div>
);
}
function Versions(props: { crate: CrateInfo }) {
const humanFileSize = (size) => {
var i = Math.floor(Math.log(size) / Math.log(1024));
return (
Number((size / Math.pow(1024, i)).toFixed(2)) +
" " +
["B", "kB", "MB", "GB", "TB"][i]
);
};
return (
<div>
{[...props.crate.versions].reverse().map((version, index) => (
<div
key={index}
className={`card text-white bg-gradient ${
index == 0 ? "bg-primary" : "bg-dark mt-2"
}`}
>
<div className="card-body d-flex align-items-center">
<h5 className="m-0">{version.vers}</h5>
<div className="text-uppercase ms-4" style={{ fontSize: ".75rem" }}>
<div>
<div className="d-inline-block">
By
<img
src="http://placekitten.com/22/22"
className="rounded-circle ms-1 me-1"
/>
{version.uploader}
</div>
<div className="ms-3 d-inline-block">
<OverlayTrigger
overlay={
<Tooltip
id={`tooltip-${props.crate.name}-version-${version.vers}-date`}
>
{new Date(version.created_at).toLocaleString()}
</Tooltip>
}
>
<span>
<Calendar3 />{" "}
<HumanTime
time={new Date(version.created_at).getTime()}
/>
</span>
</OverlayTrigger>
</div>
</div>
<div>
<div className="d-inline-block">
<Hdd /> {humanFileSize(version.size)}
</div>
<div className="ms-3 d-inline-block">
<OverlayTrigger
overlay={
<Tooltip
id={`tooltip-${props.crate.name}-version-${version.vers}-feature-${index}`}
>
<div className="text-start m-2">
{Object.keys(version.features).map(
(feature, index) => (
<div key={index}>
{version.features["default"].includes(
feature
) ? (
<CheckSquare className="me-2" />
) : (
<Square className="me-2" />
)}
{feature}
</div>
)
)}
</div>
</Tooltip>
}
>
<span>
<Check2Square /> {Object.keys(version.features).length}{" "}
Features
</span>
</OverlayTrigger>
</div>
</div>
</div>
</div>
</div>
))}
</div>
);
}
@@ -7,7 +7,7 @@
import { useAuthenticatedRequest, authenticatedEndpoint } from "../../util";
import { Plus, Trash } from "react-bootstrap-icons";
import { Button, Modal } from "react-bootstrap";
import { Button, Modal, OverlayTrigger, Tooltip } from "react-bootstrap";
import HumanTime from "react-human-time";
import ErrorPage from "../ErrorPage";
import Loading from "../Loading";
@@ -108,10 +108,22 @@
<h6 className="m-0 lh-sm">{key.name}</h6>
<pre className="m-0">{key.fingerprint}</pre>
<div className="lh-sm" style={{ fontSize: ".75rem" }}>
<span className="text-muted">
Added <HumanTime time={key.created_at} />
</span>
<span className="mx-2"></span>
<div className="text-muted d-inline-block me-3">
Added{" "}
<OverlayTrigger
overlay={
<Tooltip id={`${key.uuid}-created-at`}>
{new Date(key.created_at).toLocaleString()}
</Tooltip>
}
>
<span className="text-decoration-underline-dotted">
<HumanTime
time={new Date(key.created_at).getTime()}
/>
</span>
</OverlayTrigger>
</div>
<span
className={`text-${
key.last_used_at
@@ -123,7 +135,19 @@
>
Last used{" "}
{key.last_used_at ? (
<HumanTime time={key.last_used_at} />
<OverlayTrigger
overlay={
<Tooltip id={`${key.uuid}-last-used`}>
{new Date(key.last_used_at).toLocaleString()}
</Tooltip>
}
>
<span className="text-decoration-underline-dotted">
<HumanTime
time={new Date(key.last_used_at).getTime()}
/>
</span>
</OverlayTrigger>
) : (
<>never</>
)}
@@ -60,7 +60,7 @@
let crate_ = get_crate_with_permissions(
db.clone(),
user,
user.clone(),
metadata.inner.name.to_string(),
&[Permission::VISIBLE, Permission::PUBLISH_VERSION],
)
@@ -71,8 +71,10 @@
crate_
.publish_version(
db,
user,
file_ref,
hex::encode(Sha256::digest(crate_bytes)),
metadata_bytes.len().try_into().unwrap(),
metadata.inner.into_owned(),
metadata.meta,
)
@@ -1,8 +1,8 @@
use chartered_db::{users::User, ConnectionPool};
use axum::{extract, Json};
use chartered_db::uuid::Uuid;
use chrono::NaiveDateTime;
use chrono::{DateTime, Utc, TimeZone};
use log::warn;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -20,8 +20,8 @@
uuid: Uuid,
name: String,
fingerprint: String,
created_at: NaiveDateTime,
last_used_at: Option<NaiveDateTime>,
created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
}
pub async fn handle_get(
@@ -39,8 +39,8 @@
"INVALID".to_string()
}),
name: key.name,
created_at: key.created_at,
last_used_at: key.last_used_at,
created_at: Utc.from_local_datetime(&key.created_at).unwrap(),
last_used_at: key.last_used_at.and_then(|v| Utc.from_local_datetime(&v).single()),
})
.collect();
@@ -6,6 +6,7 @@
users::{User, UserCratePermissionValue as Permission},
ConnectionPool,
};
use chrono::TimeZone;
use chartered_types::cargo::CrateVersion;
use serde::Serialize;
use std::sync::Arc;
@@ -40,7 +41,7 @@
extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<axum::http::Response<Full<Bytes>>, Error> {
let crate_ = get_crate_with_permissions(db.clone(), user, name, &[Permission::VISIBLE]).await?;
let versions = crate_.clone().versions(db).await?;
let versions = crate_.clone().versions_with_uploader(db).await?;
@@ -51,7 +52,12 @@
info: crate_.as_ref().into(),
versions: versions
.into_iter()
.map(|v| v.into_cargo_format(&crate_))
.map(|(v, user)| ResponseVersion {
size: v.size,
created_at: chrono::Utc.from_local_datetime(&v.created_at).unwrap(),
inner: v.into_cargo_format(&crate_),
uploader: user.username,
})
.collect(),
})
.into_response())
@@ -61,7 +67,16 @@
pub struct Response<'a> {
#[serde(flatten)]
info: ResponseInfo<'a>,
versions: Vec<CrateVersion<'a>>,
versions: Vec<ResponseVersion<'a>>,
}
#[derive(Serialize)]
pub struct ResponseVersion<'a> {
#[serde(flatten)]
inner: CrateVersion<'a>,
size: i32,
created_at: chrono::DateTime<chrono::Utc>,
uploader: String,
}
#[derive(Serialize)]